Skip to content

Commit

Permalink
Close stale issues
Browse files Browse the repository at this point in the history
  • Loading branch information
Nyholm committed Nov 8, 2020
1 parent 517d05b commit 7d25039
Show file tree
Hide file tree
Showing 11 changed files with 273 additions and 3 deletions.
8 changes: 8 additions & 0 deletions .symfony.cloud.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 4 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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'
Expand Down
15 changes: 15 additions & 0 deletions src/Api/Issue/GithubIssueApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand All @@ -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'] ?? [];
}
}
2 changes: 2 additions & 0 deletions src/Api/Issue/IssueApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/Api/Issue/NullIssueApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
}
Expand Down
71 changes: 71 additions & 0 deletions src/Command/CloseStaleIssuesCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace App\Command;

use App\Api\Issue\IssueApi;
use App\Entity\Task;
use App\Service\RepositoryProvider;
use App\Service\TaskScheduler;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
* Close issues not been updated in a long while.
*
* @author Tobias Nyholm <[email protected]>
*/
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'], <<<TXT
Hey,
Is this issue still relevant? It has not been any activity in a while. I will close this if nobody makes a comment soon.
Cheers!
Carsonbot
TXT
);

// add a scheduled task to process this issue again after 2 weeks
$this->scheduler->runLater($repository, $issue['number'], Task::ACTION_CLOSE_STALE, new \DateTimeImmutable('+2weeks'));
}

return 0;
}
}
2 changes: 1 addition & 1 deletion src/Command/RunTaskCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Expand Down
10 changes: 8 additions & 2 deletions src/Service/RepositoryProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
*/
class RepositoryProvider
{
/**
* @var Repository[]
*/
private $repositories = [];

public function __construct(array $repositories)
Expand All @@ -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);
}
Expand Down
50 changes: 50 additions & 0 deletions src/Service/TaskHandler/CloseStaleIssuesHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace App\Service\TaskHandler;

use App\Api\Issue\IssueApi;
use App\Api\Label\LabelApi;
use App\Entity\Task;
use App\Service\RepositoryProvider;

/**
* @author Tobias Nyholm <[email protected]>
*/
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();
}
}
31 changes: 31 additions & 0 deletions src/Service/TaskScheduler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Service;

use App\Entity\Task;
use App\Model\Repository;
use App\Repository\TaskRepository;

/**
* Schedule a job to run later.
*
* @author Tobias Nyholm <[email protected]>
*/
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();
}
}
78 changes: 78 additions & 0 deletions tests/Service/TaskHandler/CloseStaleIssuesHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace Service\TaskHandler;

use App\Api\Issue\NullIssueApi;
use App\Api\Label\NullLabelApi;
use App\Entity\Task;
use App\Service\RepositoryProvider;
use App\Service\TaskHandler\CloseStaleIssuesHandler;
use PHPUnit\Framework\TestCase;

class CloseStaleIssuesHandlerTest extends TestCase
{
public function testHandleKeepOpen()
{
$labelApi = $this->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()));
}
}

0 comments on commit 7d25039

Please sign in to comment.