diff --git a/.symfony.cloud.yaml b/.symfony.cloud.yaml index 99a02696..c65c459c 100644 --- a/.symfony.cloud.yaml +++ b/.symfony.cloud.yaml @@ -42,5 +42,13 @@ crons: spec: '*/5 * * * *' cmd: croncape bin/console app:task:run + stale_issues_symfony: + spec: '58 12 * * *' + cmd: croncape bin/console app:issue:ping-stale symfony/symfony + + stale_issues_docs: + spec: '48 12 * * *' + cmd: croncape bin/console app:issue:ping-stale symfony/symfony-docs + relationships: database: "mydatabase:postgresql" diff --git a/config/services.yaml b/config/services.yaml index cb77713c..ed7d6ffd 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -11,6 +11,7 @@ parameters: - 'App\Subscriber\MilestoneNewPRSubscriber' - 'App\Subscriber\WelcomeFirstTimeContributorSubscriber' - 'App\Subscriber\CloseDraftPRSubscriber' + - 'App\Subscriber\RemoveStaledLabelOnCommentSubscriber' secret: '%env(SYMFONY_SECRET)%' symfony/symfony-docs: @@ -23,6 +24,7 @@ parameters: - 'App\Subscriber\BugLabelNewIssueSubscriber' - 'App\Subscriber\AutoLabelFromContentSubscriber' - 'subscriber.symfony_docs.milestone' + - 'App\Subscriber\RemoveStaledLabelOnCommentSubscriber' secret: '%env(SYMFONY_DOCS_SECRET)%' # used in a functional test @@ -38,6 +40,7 @@ parameters: - 'App\Subscriber\MilestoneNewPRSubscriber' - 'App\Subscriber\WelcomeFirstTimeContributorSubscriber' - 'App\Subscriber\CloseDraftPRSubscriber' + - 'App\Subscriber\RemoveStaledLabelOnCommentSubscriber' services: _defaults: diff --git a/src/Api/Issue/GithubIssueApi.php b/src/Api/Issue/GithubIssueApi.php index 44c81397..6743e9a3 100644 --- a/src/Api/Issue/GithubIssueApi.php +++ b/src/Api/Issue/GithubIssueApi.php @@ -31,8 +31,8 @@ public function open(Repository $repository, string $title, string $body, array ]; $issueNumber = null; - $exitingIssues = $this->searchApi->issues(sprintf('repo:%s "%s" is:open author:%s', $repository->getFullName(), $title, $this->botUsername)); - foreach ($exitingIssues['items'] ?? [] as $issue) { + $existingIssues = $this->searchApi->issues(sprintf('repo:%s "%s" is:open author:%s', $repository->getFullName(), $title, $this->botUsername)); + foreach ($existingIssues['items'] ?? [] as $issue) { $issueNumber = $issue['number']; } @@ -44,6 +44,14 @@ public function open(Repository $repository, string $title, string $body, array } } + public function lastCommentWasMadeByBot(Repository $repository, $number): bool + { + $allComments = $this->issueCommentApi->all($repository->getVendor(), $repository->getName(), $number); + $lastComment = $allComments[count($allComments) - 1] ?? []; + + return $this->botUsername === ($lastComment['user']['login'] ?? null); + } + public function show(Repository $repository, $issueNumber): array { return $this->issueApi->show($repository->getVendor(), $repository->getName(), $issueNumber); @@ -66,4 +74,11 @@ public function commentOnIssue(Repository $repository, $issueNumber, string $com ['body' => $commentBody] ); } + + public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUpdateAfter): array + { + $issues = $this->searchApi->issues(sprintf('repo:%s is:issue -linked:pr -label:"Keep open" is:open updated:<%s', $repository->getFullName(), $noUpdateAfter->format('Y-m-d'))); + + return $issues['items'] ?? []; + } } diff --git a/src/Api/Issue/IssueApi.php b/src/Api/Issue/IssueApi.php index ee7334ea..b9268672 100644 --- a/src/Api/Issue/IssueApi.php +++ b/src/Api/Issue/IssueApi.php @@ -21,6 +21,10 @@ public function show(Repository $repository, $issueNumber): array; public function commentOnIssue(Repository $repository, $issueNumber, string $commentBody); + public function lastCommentWasMadeByBot(Repository $repository, $number): bool; + + public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUpdateAfter): array; + /** * Close an issue or a pull request. */ diff --git a/src/Api/Issue/IssueType.php b/src/Api/Issue/IssueType.php new file mode 100644 index 00000000..8eee8fd0 --- /dev/null +++ b/src/Api/Issue/IssueType.php @@ -0,0 +1,13 @@ + + */ +class PingStaleIssuesCommand extends Command +{ + public const STALE_IF_NOT_UPDATED_SINCE = '-12months'; + public const MESSAGE_TWO_AFTER = '+2weeks'; + public const MESSAGE_THREE_AND_CLOSE_AFTER = '+2weeks'; + + protected static $defaultName = 'app:issue:ping-stale'; + + private $repositoryProvider; + private $issueApi; + private $scheduler; + private $commentGenerator; + private $labelApi; + + public function __construct(RepositoryProvider $repositoryProvider, IssueApi $issueApi, TaskScheduler $scheduler, StaleIssueCommentGenerator $commentGenerator, LabelApi $labelApi) + { + parent::__construct(); + $this->repositoryProvider = $repositoryProvider; + $this->issueApi = $issueApi; + $this->scheduler = $scheduler; + $this->commentGenerator = $commentGenerator; + $this->labelApi = $labelApi; + } + + protected function configure() + { + $this->addArgument('repository', InputArgument::REQUIRED, 'The full name to the repository, eg symfony/symfony-docs'); + $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do a test search without making any comments or changes'); + } + + 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; + } + + $notUpdatedAfter = new \DateTimeImmutable(self::STALE_IF_NOT_UPDATED_SINCE); + $issues = $this->issueApi->findStaleIssues($repository, $notUpdatedAfter); + + if ($input->getOption('dry-run')) { + foreach ($issues as $issue) { + $output->writeln(sprintf('Marking issue #%s as "Staled". Link https://github.com/%s/issues/%s', $issue['number'], $repository->getFullName(), $issue['number'])); + } + + return 0; + } + + foreach ($issues as $issue) { + $comment = $this->commentGenerator->getComment($this->extractType($issue)); + $this->issueApi->commentOnIssue($repository, $issue['number'], $comment); + $this->labelApi->addIssueLabel($issue['number'], 'Staled', $repository); + + // add a scheduled task to process this issue again after 2 weeks + $this->scheduler->runLater($repository, $issue['number'], Task::ACTION_INFORM_CLOSE_STALE, new \DateTimeImmutable(self::MESSAGE_TWO_AFTER)); + } + + return 0; + } + + /** + * Extract type from issue array. Make sure we priorities labels if there are + * more than one type defined. + */ + private function extractType(array $issue) + { + $types = [ + IssueType::FEATURE => false, + IssueType::BUG => false, + IssueType::RFC => false, + ]; + + foreach ($issue['labels'] as $label) { + if (isset($types[$label['name']])) { + $types[$label['name']] = true; + } + } + + foreach ($types as $type => $exists) { + if ($exists) { + return $type; + } + } + + return IssueType::UNKNOWN; + } +} diff --git a/src/Entity/Task.php b/src/Entity/Task.php index ca6eb87e..d0bea07d 100644 --- a/src/Entity/Task.php +++ b/src/Entity/Task.php @@ -17,6 +17,7 @@ class Task { const ACTION_CLOSE_STALE = 1; const ACTION_CLOSE_DRAFT = 2; + const ACTION_INFORM_CLOSE_STALE = 3; /** * @var int diff --git a/src/Service/RepositoryProvider.php b/src/Service/RepositoryProvider.php index eaf640a7..578d1c7f 100644 --- a/src/Service/RepositoryProvider.php +++ b/src/Service/RepositoryProvider.php @@ -9,6 +9,9 @@ */ class RepositoryProvider { + /** + * @var Repository[] + */ private $repositories = []; public function __construct(array $repositories) diff --git a/src/Service/StaleIssueCommentGenerator.php b/src/Service/StaleIssueCommentGenerator.php new file mode 100644 index 00000000..f2f14658 --- /dev/null +++ b/src/Service/StaleIssueCommentGenerator.php @@ -0,0 +1,84 @@ + + */ +class StaleIssueCommentGenerator +{ + /** + * Get a comment to say: "I will close this soon". + */ + public function getInformAboutClosingComment(): string + { + $messages = [ + 'Hello? This issue is about to be closed if nobody replies.', + 'Friendly ping? Should this still be open? I will close if I don\'t hear anything.', + 'Could I get a reply or should I close this?', + 'Just a quick reminder to make a comment on this. If I don\'t hear anything I\'ll close this.', + 'Friendly reminder that this issue exists. If I don\'t hear anything I\'ll close this.', + 'Could I get an answer? If I do not hear anything I will assume this issue is resolved or abandoned. Please get back to me <3', + ]; + + return $messages[array_rand($messages)]; + } + + /** + * Get a comment to say: "I'm closing this now". + */ + public function getClosingComment(): string + { + return <<bug(); + case IssueType::FEATURE: + case IssueType::RFC: + return $this->feature(); + default: + return $this->unknown(); + } + } + + private function bug(): string + { + return << + */ +class CloseStaleIssuesHandler implements TaskHandlerInterface +{ + private $issueApi; + private $repositoryProvider; + private $labelApi; + private $commentGenerator; + + public function __construct(LabelApi $labelApi, IssueApi $issueApi, RepositoryProvider $repositoryProvider, StaleIssueCommentGenerator $commentGenerator) + { + $this->issueApi = $issueApi; + $this->repositoryProvider = $repositoryProvider; + $this->labelApi = $labelApi; + $this->commentGenerator = $commentGenerator; + } + + /** + * Close the issue if the last comment was made by the bot and if "Keep open" label does not exist. + */ + public function handle(Task $task): void + { + if (null === $repository = $this->repositoryProvider->getRepository($task->getRepositoryFullName())) { + return; + } + $labels = $this->labelApi->getIssueLabels($task->getNumber(), $repository); + if (in_array('Keep open', $labels)) { + $this->labelApi->removeIssueLabel($task->getNumber(), 'Staled', $repository); + + return; + } + + if ($this->issueApi->lastCommentWasMadeByBot($repository, $task->getNumber())) { + $this->issueApi->commentOnIssue($repository, $task->getNumber(), $this->commentGenerator->getClosingComment()); + $this->issueApi->close($repository, $task->getNumber()); + } else { + $this->labelApi->removeIssueLabel($task->getNumber(), 'Staled', $repository); + } + } + + public function supports(Task $task): bool + { + return Task::ACTION_CLOSE_STALE === $task->getAction(); + } +} diff --git a/src/Service/TaskHandler/InformAboutClosingStaleIssuesHandler.php b/src/Service/TaskHandler/InformAboutClosingStaleIssuesHandler.php new file mode 100644 index 00000000..661e5e09 --- /dev/null +++ b/src/Service/TaskHandler/InformAboutClosingStaleIssuesHandler.php @@ -0,0 +1,62 @@ + + */ +class InformAboutClosingStaleIssuesHandler implements TaskHandlerInterface +{ + private $issueApi; + private $repositoryProvider; + private $labelApi; + private $commentGenerator; + private $scheduler; + + public function __construct(LabelApi $labelApi, IssueApi $issueApi, RepositoryProvider $repositoryProvider, StaleIssueCommentGenerator $commentGenerator, TaskScheduler $scheduler) + { + $this->issueApi = $issueApi; + $this->repositoryProvider = $repositoryProvider; + $this->labelApi = $labelApi; + $this->commentGenerator = $commentGenerator; + $this->scheduler = $scheduler; + } + + /** + * Close the issue if the last comment was made by the bot and if "Keep open" label does not exist. + */ + public function handle(Task $task): void + { + if (null === $repository = $this->repositoryProvider->getRepository($task->getRepositoryFullName())) { + return; + } + $labels = $this->labelApi->getIssueLabels($task->getNumber(), $repository); + if (in_array('Keep open', $labels)) { + $this->labelApi->removeIssueLabel($task->getNumber(), 'Staled', $repository); + + return; + } + + if ($this->issueApi->lastCommentWasMadeByBot($repository, $task->getNumber())) { + $this->issueApi->commentOnIssue($repository, $task->getNumber(), $this->commentGenerator->getInformAboutClosingComment()); + $this->scheduler->runLater($repository, $task->getNumber(), Task::ACTION_CLOSE_STALE, new \DateTimeImmutable(PingStaleIssuesCommand::MESSAGE_THREE_AND_CLOSE_AFTER)); + } else { + $this->labelApi->removeIssueLabel($task->getNumber(), 'Staled', $repository); + } + } + + public function supports(Task $task): bool + { + return Task::ACTION_INFORM_CLOSE_STALE === $task->getAction(); + } +} diff --git a/src/Subscriber/RemoveStaledLabelOnCommentSubscriber.php b/src/Subscriber/RemoveStaledLabelOnCommentSubscriber.php new file mode 100644 index 00000000..7787f267 --- /dev/null +++ b/src/Subscriber/RemoveStaledLabelOnCommentSubscriber.php @@ -0,0 +1,60 @@ + + */ +class RemoveStaledLabelOnCommentSubscriber implements EventSubscriberInterface +{ + private $labelApi; + private $botUsername; + + public function __construct(LabelApi $labelApi, string $botUsername) + { + $this->labelApi = $labelApi; + $this->botUsername = $botUsername; + } + + public function onIssueComment(GitHubEvent $event) + { + $data = $event->getData(); + $repository = $event->getRepository(); + + // If bot, then nothing. + if ($data['comment']['user']['login'] === $this->botUsername) { + return; + } + + $removed = false; + $issueNumber = $data['issue']['number']; + foreach ($data['issue']['labels'] as $label) { + if ('Staled' === $label['name']) { + $removed = true; + $this->labelApi->removeIssueLabel($issueNumber, 'Staled', $repository); + } + } + + if ($removed) { + $event->setResponseData([ + 'issue' => $issueNumber, + 'removed_staled_label' => true, + ]); + } + } + + public static function getSubscribedEvents() + { + return [ + GitHubEvents::ISSUE_COMMENT => 'onIssueComment', + ]; + } +} diff --git a/tests/Service/TaskHandler/CloseStaleIssuesHandlerTest.php b/tests/Service/TaskHandler/CloseStaleIssuesHandlerTest.php new file mode 100644 index 00000000..5368ddac --- /dev/null +++ b/tests/Service/TaskHandler/CloseStaleIssuesHandlerTest.php @@ -0,0 +1,79 @@ +getMockBuilder(NullLabelApi::class) + ->disableOriginalConstructor() + ->setMethods(['getIssueLabels', 'lastCommentWasMadeByBot']) + ->getMock(); + $labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug', 'Keep open']); + + $issueApi = $this->getMockBuilder(NullIssueApi::class) + ->disableOriginalConstructor() + ->setMethods(['close', 'lastCommentWasMadeByBot']) + ->getMock(); + $issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(true); + $issueApi->expects($this->never())->method('close'); + + $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]); + + $handler = new CloseStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator()); + $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable())); + } + + public function testHandleComments() + { + $labelApi = $this->getMockBuilder(NullLabelApi::class) + ->disableOriginalConstructor() + ->setMethods(['getIssueLabels', 'lastCommentWasMadeByBot']) + ->getMock(); + $labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug']); + + $issueApi = $this->getMockBuilder(NullIssueApi::class) + ->disableOriginalConstructor() + ->setMethods(['close', 'lastCommentWasMadeByBot']) + ->getMock(); + $issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(false); + $issueApi->expects($this->never())->method('close'); + + $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]); + + $handler = new CloseStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator()); + $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable())); + } + + public function testHandleStale() + { + $labelApi = $this->getMockBuilder(NullLabelApi::class) + ->disableOriginalConstructor() + ->setMethods(['getIssueLabels']) + ->getMock(); + $labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug']); + + $issueApi = $this->getMockBuilder(NullIssueApi::class) + ->disableOriginalConstructor() + ->setMethods(['close', 'lastCommentWasMadeByBot']) + ->getMock(); + $issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(true); + $issueApi->expects($this->once())->method('close'); + + $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]); + + $handler = new CloseStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator()); + $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable())); + } +} diff --git a/tests/Service/TaskHandler/InformAboutClosingStaleIssuesHandlerTest.php b/tests/Service/TaskHandler/InformAboutClosingStaleIssuesHandlerTest.php new file mode 100644 index 00000000..5e3a2dfd --- /dev/null +++ b/tests/Service/TaskHandler/InformAboutClosingStaleIssuesHandlerTest.php @@ -0,0 +1,98 @@ +getMockBuilder(NullLabelApi::class) + ->disableOriginalConstructor() + ->setMethods(['getIssueLabels', 'lastCommentWasMadeByBot']) + ->getMock(); + $labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug', 'Keep open']); + + $issueApi = $this->getMockBuilder(NullIssueApi::class) + ->disableOriginalConstructor() + ->setMethods(['close', 'lastCommentWasMadeByBot']) + ->getMock(); + $issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(true); + $issueApi->expects($this->never())->method('close'); + + $scheduler = $this->getMockBuilder(TaskScheduler::class) + ->disableOriginalConstructor() + ->setMethods(['runLater']) + ->getMock(); + $scheduler->expects($this->never())->method('runLater'); + + $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]); + + $handler = new InformAboutClosingStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator(), $scheduler); + $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable())); + } + + public function testHandleComments() + { + $labelApi = $this->getMockBuilder(NullLabelApi::class) + ->disableOriginalConstructor() + ->setMethods(['getIssueLabels', 'lastCommentWasMadeByBot']) + ->getMock(); + $labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug']); + + $issueApi = $this->getMockBuilder(NullIssueApi::class) + ->disableOriginalConstructor() + ->setMethods(['close', 'lastCommentWasMadeByBot']) + ->getMock(); + $issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(false); + $issueApi->expects($this->never())->method('close'); + + $scheduler = $this->getMockBuilder(TaskScheduler::class) + ->disableOriginalConstructor() + ->setMethods(['runLater']) + ->getMock(); + $scheduler->expects($this->never())->method('runLater'); + + $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]); + + $handler = new InformAboutClosingStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator(), $scheduler); + $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable())); + } + + public function testHandleStale() + { + $labelApi = $this->getMockBuilder(NullLabelApi::class) + ->disableOriginalConstructor() + ->setMethods(['getIssueLabels']) + ->getMock(); + $labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug']); + + $issueApi = $this->getMockBuilder(NullIssueApi::class) + ->disableOriginalConstructor() + ->setMethods(['close', 'lastCommentWasMadeByBot']) + ->getMock(); + $issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(true); + $issueApi->expects($this->never())->method('close'); + + $scheduler = $this->getMockBuilder(TaskScheduler::class) + ->disableOriginalConstructor() + ->setMethods(['runLater']) + ->getMock(); + $scheduler->expects($this->once())->method('runLater'); + + $repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]); + + $handler = new InformAboutClosingStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator(), $scheduler); + $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable())); + } +} diff --git a/tests/Subscriber/RemoveStaledLabelOnCommentSubscriberTest.php b/tests/Subscriber/RemoveStaledLabelOnCommentSubscriberTest.php new file mode 100644 index 00000000..7666f24d --- /dev/null +++ b/tests/Subscriber/RemoveStaledLabelOnCommentSubscriberTest.php @@ -0,0 +1,72 @@ +subscriber = new RemoveStaledLabelOnCommentSubscriber(new NullLabelApi(), 'carsonbot'); + $this->repository = new Repository('carsonbot-playground', 'symfony', null); + + $this->dispatcher = new EventDispatcher(); + $this->dispatcher->addSubscriber($this->subscriber); + } + + public function testOnComment() + { + $event = new GitHubEvent([ + 'issue' => ['number' => 1234, 'labels' => []], 'comment' => ['user' => ['login' => 'nyholm']], + ], $this->repository); + + $this->dispatcher->dispatch($event, GitHubEvents::ISSUE_COMMENT); + + $responseData = $event->getResponseData(); + $this->assertEmpty($responseData); + } + + public function testOnCommentOnStale() + { + $event = new GitHubEvent([ + 'issue' => ['number' => 1234, 'labels' => [['name' => 'Foo'], ['name' => 'Staled']]], 'comment' => ['user' => ['login' => 'nyholm']], + ], $this->repository); + + $this->dispatcher->dispatch($event, GitHubEvents::ISSUE_COMMENT); + + $responseData = $event->getResponseData(); + $this->assertCount(2, $responseData); + $this->assertSame(1234, $responseData['issue']); + $this->assertSame(true, $responseData['removed_staled_label']); + } + + public function testOnBotCommentOnStale() + { + $event = new GitHubEvent([ + 'issue' => ['number' => 1234, 'labels' => [['name' => 'Foo'], ['name' => 'Staled']]], 'comment' => ['user' => ['login' => 'carsonbot']], + ], $this->repository); + + $this->dispatcher->dispatch($event, GitHubEvents::ISSUE_COMMENT); + + $responseData = $event->getResponseData(); + $this->assertEmpty($responseData); + } +}