Skip to content

Commit cae3791

Browse files
committed
Close stale issues
1 parent 074528d commit cae3791

File tree

8 files changed

+237
-2
lines changed

8 files changed

+237
-2
lines changed

.symfony.cloud.yaml

+8
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,13 @@ crons:
3838
spec: '*/5 * * * *'
3939
cmd: croncape bin/console app:task:run
4040

41+
stale_issues_symfony:
42+
spec: '58 12 * * *'
43+
cmd: croncape bin/console app:issue:close-stale symfony/symfony
44+
45+
stale_issues_docs:
46+
spec: '48 12 * * *'
47+
cmd: croncape bin/console app:issue:close-stale symfony/symfony-docs
48+
4149
relationships:
4250
database: "mydatabase:postgresql"

src/Api/Issue/GithubIssueApi.php

+15
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ public function open(Repository $repository, string $title, string $body, array
4444
}
4545
}
4646

47+
public function lastCommentWasMadeByBot(Repository $repository, $number): bool
48+
{
49+
$allComments = $this->issueCommentApi->all($repository->getVendor(), $repository->getName(), $number);
50+
$lastComment = $allComments[count($allComments) - 1] ?? [];
51+
52+
return $this->botUsername === ($lastComment['user']['login'] ?? null);
53+
}
54+
4755
public function close(Repository $repository, $issueNumber)
4856
{
4957
$this->issueApi->update($repository->getVendor(), $repository->getName(), $issueNumber, ['state' => 'closed']);
@@ -61,4 +69,11 @@ public function commentOnIssue(Repository $repository, $issueNumber, string $com
6169
['body' => $commentBody]
6270
);
6371
}
72+
73+
public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUpdateAfter)
74+
{
75+
$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')));
76+
77+
return $issues['items'] ?? [];
78+
}
6479
}

src/Api/Issue/IssueApi.php

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ public function open(Repository $repository, string $title, string $body, array
1919

2020
public function commentOnIssue(Repository $repository, $issueNumber, string $commentBody);
2121

22+
public function lastCommentWasMadeByBot(Repository $repository, $number): bool;
23+
2224
/**
2325
* Close an issue or a pull request.
2426
*/

src/Api/Issue/NullIssueApi.php

+5
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ public function commentOnIssue(Repository $repository, $issueNumber, string $com
1414
{
1515
}
1616

17+
public function lastCommentWasMadeByBot(Repository $repository, $number): bool
18+
{
19+
return false;
20+
}
21+
1722
public function close(Repository $repository, $issueNumber)
1823
{
1924
}
+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace App\Command;
4+
5+
use App\Api\Issue\IssueApi;
6+
use App\Entity\Task;
7+
use App\Service\RepositoryProvider;
8+
use App\Service\TaskScheduler;
9+
use Symfony\Component\Console\Command\Command;
10+
use Symfony\Component\Console\Input\InputArgument;
11+
use Symfony\Component\Console\Input\InputInterface;
12+
use Symfony\Component\Console\Output\OutputInterface;
13+
14+
/**
15+
* Close issues not been updated in a long while.
16+
*
17+
* @author Tobias Nyholm <[email protected]>
18+
*/
19+
class CloseStaleIssuesCommand extends Command
20+
{
21+
protected static $defaultName = 'app:issue:close-stale';
22+
private $repositoryProvider;
23+
private $issueApi;
24+
private $scheduler;
25+
26+
public function __construct(RepositoryProvider $repositoryProvider, IssueApi $issueApi, TaskScheduler $scheduler)
27+
{
28+
parent::__construct();
29+
$this->repositoryProvider = $repositoryProvider;
30+
$this->issueApi = $issueApi;
31+
$this->scheduler = $scheduler;
32+
}
33+
34+
protected function configure()
35+
{
36+
$this->addArgument('repository', InputArgument::REQUIRED, 'The full name to the repository, eg symfony/symfony-docs');
37+
}
38+
39+
protected function execute(InputInterface $input, OutputInterface $output)
40+
{
41+
/** @var string $repositoryName */
42+
$repositoryName = $input->getArgument('repository');
43+
$repository = $this->repositoryProvider->getRepository($repositoryName);
44+
if (null === $repository) {
45+
$output->writeln('Repository not configured');
46+
47+
return 1;
48+
}
49+
50+
$notUpdatedAfter = new \DateTimeImmutable('-12months');
51+
$issues = $this->issueApi->findStaleIssues($repository, $notUpdatedAfter);
52+
53+
foreach ($issues as $issue) {
54+
$this->issueApi->commentOnIssue($repository, $issue['number'], <<<TXT
55+
Hey,
56+
57+
Is this issue still relevant? It has not been any activity in a while. I will close this if nobody makes a comment soon.
58+
59+
Cheers!
60+
61+
Carsonbot
62+
TXT
63+
);
64+
65+
// add a scheduled task to process this issue again after 2 weeks
66+
$this->scheduler->runLater($repository, $issue['number'], Task::ACTION_CLOSE_STALE, new \DateTimeImmutable('+2weeks'));
67+
}
68+
69+
return 0;
70+
}
71+
}

src/Service/RepositoryProvider.php

+8-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
*/
1010
class RepositoryProvider
1111
{
12+
/**
13+
* @var Repository[]
14+
*/
1215
private $repositories = [];
1316

1417
public function __construct(array $repositories)
@@ -28,14 +31,17 @@ public function __construct(array $repositories)
2831
}
2932
}
3033

31-
public function getRepository($repositoryName)
34+
public function getRepository($repositoryName): ?Repository
3235
{
3336
$repository = strtolower($repositoryName);
3437

3538
return $this->repositories[$repository] ?? null;
3639
}
3740

38-
public function getAllRepositories()
41+
/**
42+
* @return Repository[]
43+
*/
44+
public function getAllRepositories(): array
3945
{
4046
return array_values($this->repositories);
4147
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Service\TaskHandler;
6+
7+
use App\Api\Issue\IssueApi;
8+
use App\Api\Label\LabelApi;
9+
use App\Entity\Task;
10+
use App\Service\RepositoryProvider;
11+
12+
/**
13+
* @author Tobias Nyholm <[email protected]>
14+
*/
15+
class CloseStaleIssuesHandler implements TaskHandlerInterface
16+
{
17+
private $issueApi;
18+
private $repositoryProvider;
19+
private $labelApi;
20+
21+
public function __construct(LabelApi $labelApi, IssueApi $issueApi, RepositoryProvider $repositoryProvider)
22+
{
23+
$this->issueApi = $issueApi;
24+
$this->repositoryProvider = $repositoryProvider;
25+
$this->labelApi = $labelApi;
26+
}
27+
28+
/**
29+
* Close the issue if the last comment was made by the bot and if "Keep open" label does not exist.
30+
*/
31+
public function handle(Task $task): void
32+
{
33+
if (null === $repository = $this->repositoryProvider->getRepository($task->getRepositoryFullName())) {
34+
return;
35+
}
36+
$labels = $this->labelApi->getIssueLabels($task->getNumber(), $repository);
37+
if (in_array('Keep open', $labels)) {
38+
return;
39+
}
40+
41+
if ($this->issueApi->lastCommentWasMadeByBot($repository, $task->getNumber())) {
42+
$this->issueApi->close($repository, $task->getNumber());
43+
}
44+
}
45+
46+
public function supports(Task $task): bool
47+
{
48+
return Task::ACTION_CLOSE_STALE === $task->getAction();
49+
}
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Service\TaskHandler;
6+
7+
use App\Api\Issue\NullIssueApi;
8+
use App\Api\Label\NullLabelApi;
9+
use App\Entity\Task;
10+
use App\Service\RepositoryProvider;
11+
use App\Service\TaskHandler\CloseStaleIssuesHandler;
12+
use PHPUnit\Framework\TestCase;
13+
14+
class CloseStaleIssuesHandlerTest extends TestCase
15+
{
16+
public function testHandleKeepOpen()
17+
{
18+
$labelApi = $this->getMockBuilder(NullLabelApi::class)
19+
->disableOriginalConstructor()
20+
->setMethods(['getIssueLabels', 'lastCommentWasMadeByBot'])
21+
->getMock();
22+
$labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug', 'Keep open']);
23+
24+
$issueApi = $this->getMockBuilder(NullIssueApi::class)
25+
->disableOriginalConstructor()
26+
->setMethods(['close', 'lastCommentWasMadeByBot'])
27+
->getMock();
28+
$issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(true);
29+
$issueApi->expects($this->never())->method('close');
30+
31+
$repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
32+
33+
$handler = new CloseStaleIssuesHandler($labelApi, $issueApi, $repoProvider);
34+
$handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable()));
35+
}
36+
37+
public function testHandleComments()
38+
{
39+
$labelApi = $this->getMockBuilder(NullLabelApi::class)
40+
->disableOriginalConstructor()
41+
->setMethods(['getIssueLabels', 'lastCommentWasMadeByBot'])
42+
->getMock();
43+
$labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug']);
44+
45+
$issueApi = $this->getMockBuilder(NullIssueApi::class)
46+
->disableOriginalConstructor()
47+
->setMethods(['close', 'lastCommentWasMadeByBot'])
48+
->getMock();
49+
$issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(false);
50+
$issueApi->expects($this->never())->method('close');
51+
52+
$repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
53+
54+
$handler = new CloseStaleIssuesHandler($labelApi, $issueApi, $repoProvider);
55+
$handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable()));
56+
}
57+
58+
public function testHandleStale()
59+
{
60+
$labelApi = $this->getMockBuilder(NullLabelApi::class)
61+
->disableOriginalConstructor()
62+
->setMethods(['getIssueLabels'])
63+
->getMock();
64+
$labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug']);
65+
66+
$issueApi = $this->getMockBuilder(NullIssueApi::class)
67+
->disableOriginalConstructor()
68+
->setMethods(['close', 'lastCommentWasMadeByBot'])
69+
->getMock();
70+
$issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(true);
71+
$issueApi->expects($this->once())->method('close');
72+
73+
$repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
74+
75+
$handler = new CloseStaleIssuesHandler($labelApi, $issueApi, $repoProvider);
76+
$handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable()));
77+
}
78+
}

0 commit comments

Comments
 (0)