From 7d250397d3d5e6f9537ebccc75ae7b233e4102a5 Mon Sep 17 00:00:00 2001 From: Nyholm Date: Sat, 7 Nov 2020 14:09:49 +0100 Subject: [PATCH] Close stale issues --- .symfony.cloud.yaml | 8 ++ config/services.yaml | 4 + src/Api/Issue/GithubIssueApi.php | 15 ++++ src/Api/Issue/IssueApi.php | 2 + src/Api/Issue/NullIssueApi.php | 5 ++ src/Command/CloseStaleIssuesCommand.php | 71 +++++++++++++++++ src/Command/RunTaskCommand.php | 2 +- src/Service/RepositoryProvider.php | 10 ++- .../TaskHandler/CloseStaleIssuesHandler.php | 50 ++++++++++++ src/Service/TaskScheduler.php | 31 ++++++++ .../CloseStaleIssuesHandlerTest.php | 78 +++++++++++++++++++ 11 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 src/Command/CloseStaleIssuesCommand.php create mode 100644 src/Service/TaskHandler/CloseStaleIssuesHandler.php create mode 100644 src/Service/TaskScheduler.php create mode 100644 tests/Service/TaskHandler/CloseStaleIssuesHandlerTest.php diff --git a/.symfony.cloud.yaml b/.symfony.cloud.yaml index 30f969a7..37760504 100644 --- a/.symfony.cloud.yaml +++ b/.symfony.cloud.yaml @@ -38,5 +38,13 @@ crons: spec: '*/5 * * * *' cmd: croncape bin/console app:task:run + stale_issues_symfony: + spec: '58 12 * * *' + cmd: croncape bin/console app:issue:close-stale symfony/symfony + + stale_issues_docs: + spec: '48 12 * * *' + cmd: croncape bin/console app:issue:close-stale symfony/symfony-docs + relationships: database: "mydatabase:postgresql" diff --git a/config/services.yaml b/config/services.yaml index ed5871e5..443096cd 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -72,6 +72,8 @@ services: Github\Api\Search: factory: ['@Github\Client', api] arguments: [search] + calls: + - ['setPerPage', [100]] Github\Api\Issue\Labels: factory: ['@Github\Api\Issue', labels] @@ -81,6 +83,8 @@ services: Github\Api\Issue\Comments: factory: ['@Github\Api\Issue', comments] + calls: + - [ 'setPerPage', [100]] App\Api\Issue\IssueApi: '@App\Api\Issue\GithubIssueApi' App\Api\Label\LabelApi: '@App\Api\Label\GithubLabelApi' diff --git a/src/Api/Issue/GithubIssueApi.php b/src/Api/Issue/GithubIssueApi.php index 19205fd8..30967b77 100644 --- a/src/Api/Issue/GithubIssueApi.php +++ b/src/Api/Issue/GithubIssueApi.php @@ -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 close(Repository $repository, $issueNumber) { $this->issueApi->update($repository->getVendor(), $repository->getName(), $issueNumber, ['state' => 'closed']); @@ -61,4 +69,11 @@ public function commentOnIssue(Repository $repository, $issueNumber, string $com ['body' => $commentBody] ); } + + public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUpdateAfter) + { + $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 6ddc30bb..d41730fb 100644 --- a/src/Api/Issue/IssueApi.php +++ b/src/Api/Issue/IssueApi.php @@ -19,6 +19,8 @@ public function open(Repository $repository, string $title, string $body, array public function commentOnIssue(Repository $repository, $issueNumber, string $commentBody); + public function lastCommentWasMadeByBot(Repository $repository, $number): bool; + /** * Close an issue or a pull request. */ diff --git a/src/Api/Issue/NullIssueApi.php b/src/Api/Issue/NullIssueApi.php index c38b2513..aa111637 100644 --- a/src/Api/Issue/NullIssueApi.php +++ b/src/Api/Issue/NullIssueApi.php @@ -14,6 +14,11 @@ public function commentOnIssue(Repository $repository, $issueNumber, string $com { } + public function lastCommentWasMadeByBot(Repository $repository, $number): bool + { + return false; + } + public function close(Repository $repository, $issueNumber) { } diff --git a/src/Command/CloseStaleIssuesCommand.php b/src/Command/CloseStaleIssuesCommand.php new file mode 100644 index 00000000..aff36d38 --- /dev/null +++ b/src/Command/CloseStaleIssuesCommand.php @@ -0,0 +1,71 @@ + + */ +class CloseStaleIssuesCommand extends Command +{ + protected static $defaultName = 'app:issue:close-stale'; + private $repositoryProvider; + private $issueApi; + private $scheduler; + + public function __construct(RepositoryProvider $repositoryProvider, IssueApi $issueApi, TaskScheduler $scheduler) + { + parent::__construct(); + $this->repositoryProvider = $repositoryProvider; + $this->issueApi = $issueApi; + $this->scheduler = $scheduler; + } + + protected function configure() + { + $this->addArgument('repository', InputArgument::REQUIRED, 'The full name to the repository, eg symfony/symfony-docs'); + } + + 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('-12months'); + $issues = $this->issueApi->findStaleIssues($repository, $notUpdatedAfter); + + foreach ($issues as $issue) { + $this->issueApi->commentOnIssue($repository, $issue['number'], <<scheduler->runLater($repository, $issue['number'], Task::ACTION_CLOSE_STALE, new \DateTimeImmutable('+2weeks')); + } + + return 0; + } +} diff --git a/src/Command/RunTaskCommand.php b/src/Command/RunTaskCommand.php index 9b8000f0..f8b026c6 100644 --- a/src/Command/RunTaskCommand.php +++ b/src/Command/RunTaskCommand.php @@ -40,7 +40,7 @@ protected function execute(InputInterface $input, OutputInterface $output) try { $this->taskRunner->run($task); } catch (\Exception $e) { - $this->logger->error('Failed running task', ['excpetion' => $e]); + $this->logger->error('Failed running task', ['exception' => $e]); $output->writeln($e->getMessage()); } } diff --git a/src/Service/RepositoryProvider.php b/src/Service/RepositoryProvider.php index ccf661cd..b962268f 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) @@ -28,14 +31,17 @@ public function __construct(array $repositories) } } - public function getRepository($repositoryName) + public function getRepository($repositoryName): ?Repository { $repository = strtolower($repositoryName); return $this->repositories[$repository] ?? null; } - public function getAllRepositories() + /** + * @return Repository[] + */ + public function getAllRepositories(): array { return array_values($this->repositories); } diff --git a/src/Service/TaskHandler/CloseStaleIssuesHandler.php b/src/Service/TaskHandler/CloseStaleIssuesHandler.php new file mode 100644 index 00000000..37999f59 --- /dev/null +++ b/src/Service/TaskHandler/CloseStaleIssuesHandler.php @@ -0,0 +1,50 @@ + + */ +class CloseStaleIssuesHandler implements TaskHandlerInterface +{ + private $issueApi; + private $repositoryProvider; + private $labelApi; + + public function __construct(LabelApi $labelApi, IssueApi $issueApi, RepositoryProvider $repositoryProvider) + { + $this->issueApi = $issueApi; + $this->repositoryProvider = $repositoryProvider; + $this->labelApi = $labelApi; + } + + /** + * 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)) { + return; + } + + if ($this->issueApi->lastCommentWasMadeByBot($repository, $task->getNumber())) { + $this->issueApi->close($repository, $task->getNumber()); + } + } + + public function supports(Task $task): bool + { + return Task::ACTION_CLOSE_STALE === $task->getAction(); + } +} diff --git a/src/Service/TaskScheduler.php b/src/Service/TaskScheduler.php new file mode 100644 index 00000000..57d80a6a --- /dev/null +++ b/src/Service/TaskScheduler.php @@ -0,0 +1,31 @@ + + */ +class TaskScheduler +{ + private $taskRepo; + + public function __construct(TaskRepository $taskRepo) + { + $this->taskRepo = $taskRepo; + } + + public function runLater(Repository $repository, $number, int $action, \DateTimeImmutable $checkAt) + { + $task = new Task($repository->getFullName(), $number, $action, $checkAt); + $this->taskRepo->persist($task); + $this->taskRepo->flush(); + } +} diff --git a/tests/Service/TaskHandler/CloseStaleIssuesHandlerTest.php b/tests/Service/TaskHandler/CloseStaleIssuesHandlerTest.php new file mode 100644 index 00000000..f929a714 --- /dev/null +++ b/tests/Service/TaskHandler/CloseStaleIssuesHandlerTest.php @@ -0,0 +1,78 @@ +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); + $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); + $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); + $handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable())); + } +}