diff --git a/.env b/.env index 5c75d150..1c2a2328 100644 --- a/.env +++ b/.env @@ -21,7 +21,7 @@ APP_SECRET=5dd8ffca252d95e8b4fb5b2d15310e92 SYMFONY_DOCS_SECRET='' SYMFONY_SECRET='' -BOT_USERNAME='carsonbot-test' +BOT_USERNAME='carsonbot' ###> knplabs/github-api ### #GITHUB_TOKEN=XXX ###< knplabs/github-api ### diff --git a/.github/workflows/find-reviewer.yml b/.github/workflows/find-reviewer.yml new file mode 100644 index 00000000..91241e3e --- /dev/null +++ b/.github/workflows/find-reviewer.yml @@ -0,0 +1,93 @@ +name: Find Reviewer + +on: + repository_dispatch: + types: [find-reviewer] + +jobs: + find: + name: Search + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Create path + run: mkdir -p build/reviewer + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-7.4-${{ hashFiles('composer.*') }} + restore-keys: | + composer-${{ runner.os }}-7.4- + composer-${{ runner.os }}- + composer- + + - name: Download dependencies + run: composer install --no-interaction --optimize-autoloader + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: build/reviewer/var + key: nyholm-git-reviewer + + - name: Checkout GitReviewer repo + run: | + mkdir -p build/reviewer/var + mv build/reviewer/var build/reviewer_tmp + composer create-project nyholm/git-reviewer build/reviewer + mv build/reviewer_tmp build/reviewer/var + + - name: Download dependencies + run: | + cd build/reviewer + composer update --no-interaction --prefer-dist --optimize-autoloader --prefer-stable + + - name: Checkout target repo + id: target-repo + run: | + git clone https://github.com/${{ github.event.client_payload.repository }} build/target/${{ github.event.client_payload.repository }} + echo "::set-output name=dir::$(pwd)/build/target/${{ github.event.client_payload.repository }}" + + - name: Find branch base + id: target-base + run: | + cd build/reviewer + BASE=$(./git-reviewer.php pull-request:base ${{ github.event.client_payload.pull_request_number }} ${{ steps.target-repo.outputs.dir }}) + echo "::set-output name=branch::$BASE" + + - name: Checkout branch base + run: | + cd ${{ steps.target-repo.outputs.dir }} + echo ${{ steps.target-base.outputs.branch }} + git pull + git checkout ${{ steps.target-base.outputs.branch }} + + - name: Find reviwers + env: + GITHUB_TOKEN: ${{ secrets.CARSONPROD_GITHUB_TOKEN }} + run: | + cd build/reviewer + ./git-reviewer.php find ${{ github.event.client_payload.pull_request_number }} ${{ steps.target-repo.outputs.dir }} \ + --after `date +%Y-%m-%d --date="2 year ago"` \ + --ignore-path "src/Symfony/FrameworkBundle/*" \ + --ignore-path "src/Symfony/Bundle/FrameworkBundle/*" \ + --ignore-path "src/**/Tests/*" \ + --ignore-path CHANGELOG*.md \ + --ignore-path UPGRADE*.md \ + --pretty-print > output.json + + cat output.json + + - name: Write comment + env: + GITHUB_TOKEN: ${{ secrets.CARSONPROD_GITHUB_TOKEN }} + run: bin/console app:review:suggest ${{ github.event.client_payload.repository }} ${{ github.event.client_payload.pull_request_number }} ${{ github.event.client_payload.type }} `pwd`/build/reviewer/output.json diff --git a/config/services.yaml b/config/services.yaml index f3857491..64176328 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -7,6 +7,7 @@ parameters: - 'App\Subscriber\StatusChangeByReviewSubscriber' - 'App\Subscriber\NeedsReviewNewPRSubscriber' - 'App\Subscriber\BugLabelNewIssueSubscriber' + - 'App\Subscriber\FindReviewerSubscriber' - 'App\Subscriber\AutoLabelFromContentSubscriber' - 'App\Subscriber\MilestoneNewPRSubscriber' - 'App\Subscriber\WelcomeFirstTimeContributorSubscriber' @@ -23,6 +24,7 @@ parameters: - 'App\Subscriber\StatusChangeByReviewSubscriber' - 'App\Subscriber\NeedsReviewNewPRSubscriber' - 'App\Subscriber\BugLabelNewIssueSubscriber' + - 'App\Subscriber\FindReviewerSubscriber' - 'App\Subscriber\AutoLabelFromContentSubscriber' - 'App\Subscriber\UnsupportedBranchSubscriber' - 'subscriber.symfony_docs.milestone' @@ -39,6 +41,7 @@ parameters: - 'App\Subscriber\StatusChangeByReviewSubscriber' - 'App\Subscriber\NeedsReviewNewPRSubscriber' - 'App\Subscriber\BugLabelNewIssueSubscriber' + - 'App\Subscriber\FindReviewerSubscriber' - 'App\Subscriber\AutoLabelFromContentSubscriber' - 'App\Subscriber\MilestoneNewPRSubscriber' - 'App\Subscriber\WelcomeFirstTimeContributorSubscriber' @@ -51,7 +54,6 @@ services: _defaults: autowire: true autoconfigure: true - bind: string $botUsername: '%env(BOT_USERNAME)%' diff --git a/src/Api/Issue/GithubIssueApi.php b/src/Api/Issue/GithubIssueApi.php index 6743e9a3..be04bcb3 100644 --- a/src/Api/Issue/GithubIssueApi.php +++ b/src/Api/Issue/GithubIssueApi.php @@ -5,20 +5,27 @@ use App\Model\Repository; use Github\Api\Issue; use Github\Api\Issue\Comments; +use Github\Api\Issue\Timeline; +use Github\Api\PullRequest\Review; use Github\Api\Search; +use Github\Exception\RuntimeException; class GithubIssueApi implements IssueApi { private $issueCommentApi; - private $botUsername; + private $reviewApi; private $issueApi; private $searchApi; + private $timelineApi; + private $botUsername; - public function __construct(Comments $issueCommentApi, Issue $issueApi, Search $searchApi, string $botUsername) + public function __construct(Comments $issueCommentApi, Review $reviewApi, Issue $issueApi, Search $searchApi, Timeline $timelineApi, string $botUsername) { $this->issueCommentApi = $issueCommentApi; + $this->reviewApi = $reviewApi; $this->issueApi = $issueApi; $this->searchApi = $searchApi; + $this->timelineApi = $timelineApi; $this->botUsername = $botUsername; } @@ -52,6 +59,31 @@ public function lastCommentWasMadeByBot(Repository $repository, $number): bool return $this->botUsername === ($lastComment['user']['login'] ?? null); } + /** + * Has this PR or issue comments/reviews from others than the author? + */ + public function hasActivity(Repository $repository, $number): bool + { + $issue = $this->issueApi->show($repository->getVendor(), $repository->getName(), $number); + $author = $issue['user']['login'] ?? null; + + try { + $reviewComments = $this->reviewApi->all($repository->getVendor(), $repository->getName(), $number); + } catch (RuntimeException $e) { + // This was not a PR =) + $reviewComments = []; + } + + $all = array_merge($reviewComments, $this->issueCommentApi->all($repository->getVendor(), $repository->getName(), $number)); + foreach ($all as $comment) { + if (!in_array($comment['user']['login'], [$author, $this->botUsername])) { + return true; + } + } + + return false; + } + public function show(Repository $repository, $issueNumber): array { return $this->issueApi->show($repository->getVendor(), $repository->getName(), $issueNumber); @@ -81,4 +113,23 @@ public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUp return $issues['items'] ?? []; } + + public function getUsers(Repository $repository, $issueNumber): array + { + $timeline = $this->timelineApi->all($repository->getVendor(), $repository->getName(), $issueNumber); + $users = []; + foreach ($timeline as $event) { + $users[] = $event['actor']['login'] ?? $event['user']['login'] ?? $event['author']['email'] ?? ''; + if (isset($event['body'])) { + // Parse body for user reference + if (preg_match_all('|@([a-zA-z_\-0-9]+)|', $event['body'], $matches)) { + foreach ($matches[1] as $match) { + $users[] = $match; + } + } + } + } + + return array_map(function ($a) { return strtolower($a); }, array_unique($users)); + } } diff --git a/src/Api/Issue/IssueApi.php b/src/Api/Issue/IssueApi.php index b9268672..6efd26aa 100644 --- a/src/Api/Issue/IssueApi.php +++ b/src/Api/Issue/IssueApi.php @@ -21,6 +21,8 @@ public function show(Repository $repository, $issueNumber): array; public function commentOnIssue(Repository $repository, $issueNumber, string $commentBody); + public function hasActivity(Repository $repository, $number): bool; + public function lastCommentWasMadeByBot(Repository $repository, $number): bool; public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUpdateAfter): array; @@ -29,4 +31,9 @@ public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUp * Close an issue or a pull request. */ public function close(Repository $repository, $issueNumber); + + /** + * Get users active or mentioned in this issue/pull request. + */ + public function getUsers(Repository $repository, $issueNumber): array; } diff --git a/src/Api/Issue/NullIssueApi.php b/src/Api/Issue/NullIssueApi.php index 78970fc6..ab2a93cb 100644 --- a/src/Api/Issue/NullIssueApi.php +++ b/src/Api/Issue/NullIssueApi.php @@ -19,6 +19,11 @@ public function commentOnIssue(Repository $repository, $issueNumber, string $com { } + public function hasActivity(Repository $repository, $number): bool + { + return false; + } + public function lastCommentWasMadeByBot(Repository $repository, $number): bool { return false; @@ -32,4 +37,9 @@ public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUp public function close(Repository $repository, $issueNumber) { } + + public function getUsers(Repository $repository, $issueNumber): array + { + return []; + } } diff --git a/src/Api/Issue/StdErrIssueApi.php b/src/Api/Issue/StdErrIssueApi.php new file mode 100644 index 00000000..fed88d6b --- /dev/null +++ b/src/Api/Issue/StdErrIssueApi.php @@ -0,0 +1,32 @@ + + */ +class StdErrIssueApi extends GithubIssueApi +{ + public function open(Repository $repository, string $title, string $body, array $labels) + { + error_log(sprintf('Open new issue on %s', $repository->getFullName())); + error_log('Title: '.$title); + error_log('Labels: '.json_encode($labels)); + error_log($body); + } + + public function close(Repository $repository, $issueNumber) + { + error_log(sprintf('Closing %s#%d', $repository->getFullName(), $issueNumber)); + } + + public function commentOnIssue(Repository $repository, $issueNumber, string $commentBody) + { + error_log(sprintf('Commenting on %s#%d', $repository->getFullName(), $issueNumber)); + error_log($commentBody); + } +} diff --git a/src/Command/SuggestReviewerCommand.php b/src/Command/SuggestReviewerCommand.php new file mode 100644 index 00000000..65ab9357 --- /dev/null +++ b/src/Command/SuggestReviewerCommand.php @@ -0,0 +1,110 @@ + + */ +class SuggestReviewerCommand extends Command +{ + public const TYPE_SUGGEST = 'suggest'; + public const TYPE_DEMAND = 'demand'; + protected static $defaultName = 'app:review:suggest'; + private $issueApi; + private $repositoryProvider; + private $reviewerFilter; + private $complementGenerator; + + public function __construct(RepositoryProvider $repositoryProvider, IssueApi $issueApi, ReviewerFilter $reviewerFilter, ComplementGenerator $complementGenerator) + { + parent::__construct(); + $this->issueApi = $issueApi; + $this->repositoryProvider = $repositoryProvider; + $this->reviewerFilter = $reviewerFilter; + $this->complementGenerator = $complementGenerator; + } + + protected function configure() + { + $this->addArgument('repository', InputArgument::REQUIRED, 'The full name to the repository, eg symfony/symfony-docs'); + $this->addArgument('number', InputArgument::REQUIRED, 'Pull request number'); + $this->addArgument('type', InputArgument::REQUIRED, 'Type is either "suggest" or "demand".'); + $this->addArgument('contributor_json', InputArgument::REQUIRED, 'The path to the issue body text file'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + /** @var string $repositoryName */ + $repositoryName = $input->getArgument('repository'); + $repository = $this->repositoryProvider->getRepository($repositoryName); + if (null === $repository) { + $output->writeln('Repository not configured'); + + return 1; + } + + /** @var string $pullRequestNumber */ + $pullRequestNumber = $input->getArgument('number'); + /** @var string $type */ + $type = $input->getArgument('type'); + if (self::TYPE_DEMAND !== $type && self::TYPE_SUGGEST !== $type) { + $output->writeln(sprintf('Invalid type. You provided: "%s"', $type)); + + return 1; + } + + /** @var string $path */ + $path = $input->getArgument('contributor_json'); + $json = file_get_contents($path); + if (false === $json) { + return 1; + } + + $contributors = json_decode($json, true); + $reviewer = $this->reviewerFilter->suggestReviewer($contributors, $repository, $pullRequestNumber); + if (null === $reviewer) { + if (self::TYPE_SUGGEST === $type) { + $output->writeln('We could not find any reviewer.'); + + return 0; + } + + $this->issueApi->commentOnIssue($repository, $pullRequestNumber, 'I\'m sorry. I could not find any suitable reviewer.'); + + return 0; + } + + if (self::TYPE_DEMAND === $type) { + $this->issueApi->commentOnIssue($repository, $pullRequestNumber, sprintf('@%s could maybe review this PR?', $reviewer)); + + return 0; + } + + $complement = $this->complementGenerator->getPullRequestComplement(); + $this->issueApi->commentOnIssue($repository, $pullRequestNumber, << + */ +class ReviewerFilter +{ + private $issueApi; + + // These users will never be pinged for a review + private $blocked = [ + 'fabpot', 'tobion', 'nicolas-grekas', 'stof', 'dunglas', 'jakzal', + 'xabbuh', 'javiereguiluz', 'lyrixx', 'weaverryan', 'chalasr', 'ogizanagi', + 'sroze', 'yceruto', 'nyholm', 'wouterj', 'derrabus', 'jderusse', + ]; + + public function __construct(IssueApi $issueApi) + { + $this->issueApi = $issueApi; + } + + public function suggestReviewer(array $possibleReviewers, Repository $repository, $pullRequestNumber): ?string + { + // Dont suggest block listed + $possibleReviewers = $this->filterBlocked($possibleReviewers); + $possibleReviewers = $this->filterUsersInvolved($possibleReviewers, $repository, $pullRequestNumber); + + foreach ($possibleReviewers as $reviewer) { + return $reviewer['username']; + } + + return null; + } + + /** + * Dont get people involved in the PR already. + */ + private function filterUsersInvolved(array $possibleReviewers, Repository $repository, $pullRequestNumber): array + { + $output = []; + if (empty($possibleReviewers)) { + return $output; + } + + $users = $this->issueApi->getUsers($repository, $pullRequestNumber); + foreach ($possibleReviewers as $reviewer) { + $username = strtolower($reviewer['username']); + if (in_array($username, $users) || in_array($reviewer['email'] ?? '', $users)) { + continue; + } + + $output[] = $reviewer; + } + + return $output; + } + + private function filterBlocked(array $possibleReviewers): array + { + $output = []; + + foreach ($possibleReviewers as $reviewer) { + if (empty($reviewer['username'])) { + continue; + } + + $username = strtolower($reviewer['username']); + if (in_array($username, $this->blocked)) { + continue; + } + + $output[] = $reviewer; + } + + return $output; + } +} diff --git a/src/Service/TaskHandler/SuggestReviewerHandler.php b/src/Service/TaskHandler/SuggestReviewerHandler.php new file mode 100644 index 00000000..16b9d8a1 --- /dev/null +++ b/src/Service/TaskHandler/SuggestReviewerHandler.php @@ -0,0 +1,68 @@ + + */ +class SuggestReviewerHandler implements TaskHandlerInterface +{ + private $issueApi; + private $repositoryProvider; + private $pullRequestApi; + private $statusApi; + private $scheduler; + private $logger; + + public function __construct(PullRequestApi $pullRequestApi, IssueApi $issueApi, RepositoryProvider $repositoryProvider, StatusApi $statusApi, TaskScheduler $scheduler, LoggerInterface $logger) + { + $this->issueApi = $issueApi; + $this->repositoryProvider = $repositoryProvider; + $this->pullRequestApi = $pullRequestApi; + $this->statusApi = $statusApi; + $this->scheduler = $scheduler; + $this->logger = $logger; + } + + /** + * We want to check if the PR has no activity, then suggest a reviewer. + */ + public function handle(Task $task): void + { + $repository = $this->repositoryProvider->getRepository($task->getRepositoryFullName()); + if (!$repository) { + $this->logger->error(sprintf('RepositoryProvider returned nothing for "%s" ', $task->getRepositoryFullName())); + + return; + } + + if ($this->issueApi->hasActivity($repository, $task->getNumber())) { + return; + } + + $status = $this->statusApi->getIssueStatus($task->getNumber(), $repository); + if (Status::NEEDS_REVIEW === $status || null === $status) { + $this->pullRequestApi->findReviewer($repository, $task->getNumber(), SuggestReviewerCommand::TYPE_SUGGEST); + } else { + // Try again later + $this->scheduler->runLater($repository, $task->getNumber(), Task::ACTION_SUGGEST_REVIEWER, new \DateTimeImmutable('+20hours')); + } + } + + public function supports(Task $task): bool + { + return Task::ACTION_SUGGEST_REVIEWER === $task->getAction(); + } +} diff --git a/src/Subscriber/AbstractStatusChangeSubscriber.php b/src/Subscriber/AbstractStatusChangeSubscriber.php index 37778f16..3dc4f0d8 100644 --- a/src/Subscriber/AbstractStatusChangeSubscriber.php +++ b/src/Subscriber/AbstractStatusChangeSubscriber.php @@ -36,7 +36,7 @@ protected function parseStatusFromText($body) $formatting = '[\\s\\*]*'; // Match first character after "status:" // Case insensitive ("i"), ignores formatting with "*" before or after the ":" - $pattern = "~(?=\n|^)${formatting}status${formatting}:${formatting}[\"']?($triggerWord)[\"']?${formatting}[.!]?${formatting}(?<=\r\n|\n|$)~i"; + $pattern = "~(?=\n|^)(?:\@carsonbot)?${formatting}status${formatting}:${formatting}[\"']?($triggerWord)[\"']?${formatting}[.!]?${formatting}(?<=\r\n|\n|$)~i"; if (preg_match_all($pattern, $body, $matches)) { // Second subpattern = first status character diff --git a/src/Subscriber/FindReviewerSubscriber.php b/src/Subscriber/FindReviewerSubscriber.php new file mode 100644 index 00000000..b977cfc8 --- /dev/null +++ b/src/Subscriber/FindReviewerSubscriber.php @@ -0,0 +1,75 @@ + + */ +class FindReviewerSubscriber implements EventSubscriberInterface +{ + private $pullRequestApi; + private $botUsername; + private $scheduler; + + public function __construct(PullRequestApi $pullRequestApi, TaskScheduler $scheduler, string $botUsername) + { + $this->pullRequestApi = $pullRequestApi; + $this->botUsername = $botUsername; + $this->scheduler = $scheduler; + } + + public function onPullRequest(GitHubEvent $event) + { + $data = $event->getData(); + if (!in_array($data['action'], ['opened', 'ready_for_review']) || ($data['pull_request']['draft'] ?? false)) { + return; + } + + $repository = $event->getRepository(); + + // set scheduled task to run in 20 hours + $this->scheduler->runLater($repository, $data['number'], Task::ACTION_SUGGEST_REVIEWER, new \DateTimeImmutable('+20hours')); + } + + /** + * When somebody makes a comment on an issue or pull request. + * If it includes the bot name and "review" on the same line, then try to find reviewer. + */ + public function onComment(GitHubEvent $event) + { + $data = $event->getData(); + if ('created' !== $data['action']) { + return; + } + + if (false === strpos($data['comment']['body'], $this->botUsername)) { + return; + } + + // Search for "review" + if (preg_match('~\@'.$this->botUsername.'(\W[^(status)].*|\W)(\breviewer\b|\breview\b)~i', $data['comment']['body'] ?? '')) { + $number = $data['issue']['number']; + $this->pullRequestApi->findReviewer($event->getRepository(), $number, SuggestReviewerCommand::TYPE_DEMAND); + $event->setResponseData([ + 'issue' => $number, + 'suggest-review' => true, + ]); + } + } + + public static function getSubscribedEvents() + { + return [ + GitHubEvents::PULL_REQUEST => 'onPullRequest', + GitHubEvents::ISSUE_COMMENT => 'onComment', + ]; + } +} diff --git a/tests/Controller/WebhookControllerTest.php b/tests/Controller/WebhookControllerTest.php index e9ac25be..1d83270d 100644 --- a/tests/Controller/WebhookControllerTest.php +++ b/tests/Controller/WebhookControllerTest.php @@ -94,6 +94,11 @@ public function getTests() 'issues.labeled.waitingCodeMerge.json', ['pull_request' => 2, 'milestone' => 'next'], ], + 'Suggest review on demand' => [ + 'issue_comment', + 'pull_request.comment.json', + ['issue' => 7, 'status_change' => null, 'suggest-review' => true], + ], ]; } } diff --git a/tests/Subscriber/FindReviewerSubscriberTest.php b/tests/Subscriber/FindReviewerSubscriberTest.php new file mode 100644 index 00000000..75718d33 --- /dev/null +++ b/tests/Subscriber/FindReviewerSubscriberTest.php @@ -0,0 +1,93 @@ +pullRequestApi = $this->createMock(NullPullRequestApi::class); + $this->taskScheduler = $this->createMock(TaskScheduler::class); + + $this->subscriber = new FindReviewerSubscriber($this->pullRequestApi, $this->taskScheduler, 'carsonbot'); + $this->repository = new Repository('carsonbot-playground', 'symfony', null); + + $this->dispatcher = new EventDispatcher(); + $this->dispatcher->addSubscriber($this->subscriber); + } + + /** + * @dataProvider commentProvider + */ + public function testOnComment(string $body, bool $valid) + { + if ($valid) { + $this->pullRequestApi->expects($this->once()) + ->method('findReviewer') + ->with($this->repository, 4711, SuggestReviewerCommand::TYPE_DEMAND); + } else { + $this->pullRequestApi->expects($this->never())->method('findReviewer'); + } + + $event = new GitHubEvent([ + 'action' => 'created', + 'issue' => [ + 'number' => 4711, + ], + 'comment' => [ + 'body' => $body, + ], + ], $this->repository); + + $this->dispatcher->dispatch($event, GitHubEvents::ISSUE_COMMENT); + $responseData = $event->getResponseData(); + + if ($valid) { + $this->assertCount(2, $responseData); + $this->assertSame(4711, $responseData['issue']); + $this->assertSame(true, $responseData['suggest-review']); + } else { + $this->assertEmpty($responseData); + } + } + + public function commentProvider() + { + yield ['@carsonbot reviewer', true]; + yield ['@carsonbot review', true]; + yield ['@carsonbot find a reviewer', true]; + yield ['@carsonbot review please', true]; + yield ['Please @carsonbot, fetch a reviewer', true]; + yield ['Some random', false]; + yield ['@carsonbot foobar', false]; + yield ['See the review please', false]; + yield ["@carsonbot please\nfind a reviewer for me", false]; + + foreach (ValidCommandProvider::get() as $data) { + yield [$data[0], FindReviewerSubscriber::class === $data[1]]; + } + } +} diff --git a/tests/Subscriber/StatusChangeByReviewSubscriberTest.php b/tests/Subscriber/StatusChangeByReviewSubscriberTest.php index 7a3d04a3..215ab7ea 100644 --- a/tests/Subscriber/StatusChangeByReviewSubscriberTest.php +++ b/tests/Subscriber/StatusChangeByReviewSubscriberTest.php @@ -7,6 +7,7 @@ use App\Event\GitHubEvent; use App\GitHubEvents; use App\Model\Repository; +use App\Subscriber\StatusChangeByCommentSubscriber; use App\Subscriber\StatusChangeByReviewSubscriber; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -99,6 +100,26 @@ public function getCommentsForStatusChange() ]; } + /** + * @dataProvider \App\Tests\ValidCommandProvider::get() + */ + public function testCommandCollision($comment, $subscriber) + { + if (StatusChangeByCommentSubscriber::class === $subscriber) { + $this->statusApi->expects($this->once())->method('setIssueStatus'); + } else { + $this->statusApi->expects($this->never())->method('setIssueStatus'); + } + + $event = new GitHubEvent([ + 'action' => 'submitted', + 'pull_request' => ['number' => 1234, 'user' => ['login' => 'weaverryan']], + 'review' => ['state' => 'commented', 'body' => $comment, 'user' => ['login' => 'leannapelham']], + ], $this->repository); + + $this->dispatcher->dispatch($event, GitHubEvents::PULL_REQUEST_REVIEW); + } + public function testOnIssueCommentAuthorSelfReview() { $this->statusApi->expects($this->never()) diff --git a/tests/ValidCommandProvider.php b/tests/ValidCommandProvider.php new file mode 100644 index 00000000..81b17528 --- /dev/null +++ b/tests/ValidCommandProvider.php @@ -0,0 +1,26 @@ + + */ +class ValidCommandProvider +{ + public static function get() + { + yield ['Status: needs review', StatusChangeByCommentSubscriber::class]; + yield ['Status: needs work', StatusChangeByCommentSubscriber::class]; + yield ['Status: reviewed', StatusChangeByCommentSubscriber::class]; + yield ['Status: works for me', StatusChangeByCommentSubscriber::class]; + yield ['@carsonbot Status: needs review', StatusChangeByCommentSubscriber::class]; + yield ['@carsonbot Status: reviewed', StatusChangeByCommentSubscriber::class]; + yield ['@carsonbot review', FindReviewerSubscriber::class]; + yield ['@carsonbot reviewer', FindReviewerSubscriber::class]; + } +} diff --git a/tests/webhook_examples/pull_request.comment.header.txt b/tests/webhook_examples/pull_request.comment.header.txt new file mode 100644 index 00000000..54b49804 --- /dev/null +++ b/tests/webhook_examples/pull_request.comment.header.txt @@ -0,0 +1,10 @@ +Request URL: https://carson.symfonycasts.com: +Request method: POST +Accept: */* +content-type: application/json +User-Agent: GitHub-Hookshot/8ca99f5 +X-GitHub-Delivery: ec217900-21a1-11eb-8dce-ab7f71597369 +X-GitHub-Event: issue_comment +X-GitHub-Hook-ID: 259877073 +X-GitHub-Hook-Installation-Target-ID: 309178226 +X-GitHub-Hook-Installation-Target-Type: repository diff --git a/tests/webhook_examples/pull_request.comment.json b/tests/webhook_examples/pull_request.comment.json new file mode 100644 index 00000000..71e389d7 --- /dev/null +++ b/tests/webhook_examples/pull_request.comment.json @@ -0,0 +1,227 @@ +{ + "action": "created", + "issue": { + "url": "https://api.github.com/repos/carsonbot-playground/symfony/issues/7", + "repository_url": "https://api.github.com/repos/carsonbot-playground/symfony", + "labels_url": "https://api.github.com/repos/carsonbot-playground/symfony/issues/7/labels{/name}", + "comments_url": "https://api.github.com/repos/carsonbot-playground/symfony/issues/7/comments", + "events_url": "https://api.github.com/repos/carsonbot-playground/symfony/issues/7/events", + "html_url": "https://github.com/carsonbot-playground/symfony/pull/7", + "id": 738224596, + "node_id": "MDExOlB1bGxSZXF1ZXN0NTE3MTI0NTMz", + "number": 7, + "title": "Update README.md", + "user": { + "login": "Nyholm", + "id": 1275206, + "node_id": "MDQ6VXNlcjEyNzUyMDY=", + "avatar_url": "https://avatars2.githubusercontent.com/u/1275206?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Nyholm", + "html_url": "https://github.com/Nyholm", + "followers_url": "https://api.github.com/users/Nyholm/followers", + "following_url": "https://api.github.com/users/Nyholm/following{/other_user}", + "gists_url": "https://api.github.com/users/Nyholm/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Nyholm/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Nyholm/subscriptions", + "organizations_url": "https://api.github.com/users/Nyholm/orgs", + "repos_url": "https://api.github.com/users/Nyholm/repos", + "events_url": "https://api.github.com/users/Nyholm/events{/privacy}", + "received_events_url": "https://api.github.com/users/Nyholm/received_events", + "type": "User", + "site_admin": false + }, + "labels": [ + { + "id": 2472379219, + "node_id": "MDU6TGFiZWwyNDcyMzc5MjE5", + "url": "https://api.github.com/repos/carsonbot-playground/symfony/labels/Status:%20Needs%20Review", + "name": "Status: Needs Review", + "color": "99dd61", + "default": false, + "description": "" + } + ], + "state": "closed", + "locked": false, + "assignee": null, + "assignees": [ + + ], + "milestone": null, + "comments": 1, + "created_at": "2020-11-07T11:30:52Z", + "updated_at": "2020-11-08T09:08:10Z", + "closed_at": "2020-11-07T13:38:55Z", + "author_association": "CONTRIBUTOR", + "active_lock_reason": null, + "pull_request": { + "url": "https://api.github.com/repos/carsonbot-playground/symfony/pulls/7", + "html_url": "https://github.com/carsonbot-playground/symfony/pull/7", + "diff_url": "https://github.com/carsonbot-playground/symfony/pull/7.diff", + "patch_url": "https://github.com/carsonbot-playground/symfony/pull/7.patch" + }, + "body": "", + "performed_via_github_app": null + }, + "comment": { + "url": "https://api.github.com/repos/carsonbot-playground/symfony/issues/comments/723549419", + "html_url": "https://github.com/carsonbot-playground/symfony/pull/7#issuecomment-723549419", + "issue_url": "https://api.github.com/repos/carsonbot-playground/symfony/issues/7", + "id": 723549419, + "node_id": "MDEyOklzc3VlQ29tbWVudDcyMzU0OTQxOQ==", + "user": { + "login": "Nyholm", + "id": 1275206, + "node_id": "MDQ6VXNlcjEyNzUyMDY=", + "avatar_url": "https://avatars2.githubusercontent.com/u/1275206?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Nyholm", + "html_url": "https://github.com/Nyholm", + "followers_url": "https://api.github.com/users/Nyholm/followers", + "following_url": "https://api.github.com/users/Nyholm/following{/other_user}", + "gists_url": "https://api.github.com/users/Nyholm/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Nyholm/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Nyholm/subscriptions", + "organizations_url": "https://api.github.com/users/Nyholm/orgs", + "repos_url": "https://api.github.com/users/Nyholm/repos", + "events_url": "https://api.github.com/users/Nyholm/events{/privacy}", + "received_events_url": "https://api.github.com/users/Nyholm/received_events", + "type": "User", + "site_admin": false + }, + "created_at": "2020-11-08T09:08:10Z", + "updated_at": "2020-11-08T09:08:10Z", + "author_association": "CONTRIBUTOR", + "body": "Hello, \r\n@carsonbot-test find me a reviewer to this please. \r\n\r\n//Tobias", + "performed_via_github_app": null + }, + "repository": { + "id": 309178226, + "node_id": "MDEwOlJlcG9zaXRvcnkzMDkxNzgyMjY=", + "name": "symfony", + "full_name": "carsonbot-playground/symfony", + "private": false, + "owner": { + "login": "carsonbot-playground", + "id": 73797097, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjczNzk3MDk3", + "avatar_url": "https://avatars1.githubusercontent.com/u/73797097?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/carsonbot-playground", + "html_url": "https://github.com/carsonbot-playground", + "followers_url": "https://api.github.com/users/carsonbot-playground/followers", + "following_url": "https://api.github.com/users/carsonbot-playground/following{/other_user}", + "gists_url": "https://api.github.com/users/carsonbot-playground/gists{/gist_id}", + "starred_url": "https://api.github.com/users/carsonbot-playground/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/carsonbot-playground/subscriptions", + "organizations_url": "https://api.github.com/users/carsonbot-playground/orgs", + "repos_url": "https://api.github.com/users/carsonbot-playground/repos", + "events_url": "https://api.github.com/users/carsonbot-playground/events{/privacy}", + "received_events_url": "https://api.github.com/users/carsonbot-playground/received_events", + "type": "Organization", + "site_admin": false + }, + "html_url": "https://github.com/carsonbot-playground/symfony", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/carsonbot-playground/symfony", + "forks_url": "https://api.github.com/repos/carsonbot-playground/symfony/forks", + "keys_url": "https://api.github.com/repos/carsonbot-playground/symfony/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/carsonbot-playground/symfony/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/carsonbot-playground/symfony/teams", + "hooks_url": "https://api.github.com/repos/carsonbot-playground/symfony/hooks", + "issue_events_url": "https://api.github.com/repos/carsonbot-playground/symfony/issues/events{/number}", + "events_url": "https://api.github.com/repos/carsonbot-playground/symfony/events", + "assignees_url": "https://api.github.com/repos/carsonbot-playground/symfony/assignees{/user}", + "branches_url": "https://api.github.com/repos/carsonbot-playground/symfony/branches{/branch}", + "tags_url": "https://api.github.com/repos/carsonbot-playground/symfony/tags", + "blobs_url": "https://api.github.com/repos/carsonbot-playground/symfony/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/carsonbot-playground/symfony/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/carsonbot-playground/symfony/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/carsonbot-playground/symfony/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/carsonbot-playground/symfony/statuses/{sha}", + "languages_url": "https://api.github.com/repos/carsonbot-playground/symfony/languages", + "stargazers_url": "https://api.github.com/repos/carsonbot-playground/symfony/stargazers", + "contributors_url": "https://api.github.com/repos/carsonbot-playground/symfony/contributors", + "subscribers_url": "https://api.github.com/repos/carsonbot-playground/symfony/subscribers", + "subscription_url": "https://api.github.com/repos/carsonbot-playground/symfony/subscription", + "commits_url": "https://api.github.com/repos/carsonbot-playground/symfony/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/carsonbot-playground/symfony/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/carsonbot-playground/symfony/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/carsonbot-playground/symfony/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/carsonbot-playground/symfony/contents/{+path}", + "compare_url": "https://api.github.com/repos/carsonbot-playground/symfony/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/carsonbot-playground/symfony/merges", + "archive_url": "https://api.github.com/repos/carsonbot-playground/symfony/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/carsonbot-playground/symfony/downloads", + "issues_url": "https://api.github.com/repos/carsonbot-playground/symfony/issues{/number}", + "pulls_url": "https://api.github.com/repos/carsonbot-playground/symfony/pulls{/number}", + "milestones_url": "https://api.github.com/repos/carsonbot-playground/symfony/milestones{/number}", + "notifications_url": "https://api.github.com/repos/carsonbot-playground/symfony/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/carsonbot-playground/symfony/labels{/name}", + "releases_url": "https://api.github.com/repos/carsonbot-playground/symfony/releases{/id}", + "deployments_url": "https://api.github.com/repos/carsonbot-playground/symfony/deployments", + "created_at": "2020-11-01T20:05:40Z", + "updated_at": "2020-11-07T13:38:57Z", + "pushed_at": "2020-11-07T13:38:55Z", + "git_url": "git://github.com/carsonbot-playground/symfony.git", + "ssh_url": "git@github.com:carsonbot-playground/symfony.git", + "clone_url": "https://github.com/carsonbot-playground/symfony.git", + "svn_url": "https://github.com/carsonbot-playground/symfony", + "homepage": null, + "size": 3, + "stargazers_count": 1, + "watchers_count": 1, + "language": null, + "has_issues": true, + "has_projects": false, + "has_downloads": true, + "has_wiki": false, + "has_pages": false, + "forks_count": 1, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 6, + "license": null, + "forks": 1, + "open_issues": 6, + "watchers": 1, + "default_branch": "master" + }, + "organization": { + "login": "carsonbot-playground", + "id": 73797097, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjczNzk3MDk3", + "url": "https://api.github.com/orgs/carsonbot-playground", + "repos_url": "https://api.github.com/orgs/carsonbot-playground/repos", + "events_url": "https://api.github.com/orgs/carsonbot-playground/events", + "hooks_url": "https://api.github.com/orgs/carsonbot-playground/hooks", + "issues_url": "https://api.github.com/orgs/carsonbot-playground/issues", + "members_url": "https://api.github.com/orgs/carsonbot-playground/members{/member}", + "public_members_url": "https://api.github.com/orgs/carsonbot-playground/public_members{/member}", + "avatar_url": "https://avatars1.githubusercontent.com/u/73797097?v=4", + "description": null + }, + "sender": { + "login": "Nyholm", + "id": 1275206, + "node_id": "MDQ6VXNlcjEyNzUyMDY=", + "avatar_url": "https://avatars2.githubusercontent.com/u/1275206?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Nyholm", + "html_url": "https://github.com/Nyholm", + "followers_url": "https://api.github.com/users/Nyholm/followers", + "following_url": "https://api.github.com/users/Nyholm/following{/other_user}", + "gists_url": "https://api.github.com/users/Nyholm/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Nyholm/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Nyholm/subscriptions", + "organizations_url": "https://api.github.com/users/Nyholm/orgs", + "repos_url": "https://api.github.com/users/Nyholm/repos", + "events_url": "https://api.github.com/users/Nyholm/events{/privacy}", + "received_events_url": "https://api.github.com/users/Nyholm/received_events", + "type": "User", + "site_admin": false + } +}