Skip to content

Commit 9eaeaf4

Browse files
committed
Close stale issues
1 parent 4ff0fd6 commit 9eaeaf4

10 files changed

+347
-0
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 show(Repository $repository, $issueNumber): array
4856
{
4957
return $this->issueApi->show($repository->getVendor(), $repository->getName(), $issueNumber);
@@ -66,4 +74,11 @@ public function commentOnIssue(Repository $repository, $issueNumber, string $com
6674
['body' => $commentBody]
6775
);
6876
}
77+
78+
public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUpdateAfter): array
79+
{
80+
$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')));
81+
82+
return $issues['items'] ?? [];
83+
}
6984
}

src/Api/Issue/IssueApi.php

+4
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ public function show(Repository $repository, $issueNumber): array;
2121

2222
public function commentOnIssue(Repository $repository, $issueNumber, string $commentBody);
2323

24+
public function lastCommentWasMadeByBot(Repository $repository, $number): bool;
25+
26+
public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUpdateAfter): array;
27+
2428
/**
2529
* Close an issue or a pull request.
2630
*/

src/Api/Issue/IssueType.php

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Api\Issue;
6+
7+
class IssueType
8+
{
9+
public const BUG = 'Bug';
10+
public const FEATURE = 'Feature';
11+
public const UNKNOWN = 'Unknown';
12+
public const RFC = 'RFC';
13+
}

src/Api/Issue/NullIssueApi.php

+10
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ public function commentOnIssue(Repository $repository, $issueNumber, string $com
1919
{
2020
}
2121

22+
public function lastCommentWasMadeByBot(Repository $repository, $number): bool
23+
{
24+
return false;
25+
}
26+
27+
public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUpdateAfter): array
28+
{
29+
return [];
30+
}
31+
2232
public function close(Repository $repository, $issueNumber)
2333
{
2434
}
+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
namespace App\Command;
4+
5+
use App\Api\Issue\IssueApi;
6+
use App\Api\Issue\IssueType;
7+
use App\Entity\Task;
8+
use App\Service\RepositoryProvider;
9+
use App\Service\StaleIssueCommentGenerator;
10+
use App\Service\TaskScheduler;
11+
use Symfony\Component\Console\Command\Command;
12+
use Symfony\Component\Console\Input\InputArgument;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use Symfony\Component\Console\Output\OutputInterface;
15+
16+
/**
17+
* Close issues not been updated in a long while.
18+
*
19+
* @author Tobias Nyholm <[email protected]>
20+
*/
21+
class CloseStaleIssuesCommand extends Command
22+
{
23+
protected static $defaultName = 'app:issue:close-stale';
24+
private $repositoryProvider;
25+
private $issueApi;
26+
private $scheduler;
27+
private $commentGenerator;
28+
29+
public function __construct(RepositoryProvider $repositoryProvider, IssueApi $issueApi, TaskScheduler $scheduler, StaleIssueCommentGenerator $commentGenerator)
30+
{
31+
parent::__construct();
32+
$this->repositoryProvider = $repositoryProvider;
33+
$this->issueApi = $issueApi;
34+
$this->scheduler = $scheduler;
35+
$this->commentGenerator = $commentGenerator;
36+
}
37+
38+
protected function configure()
39+
{
40+
$this->addArgument('repository', InputArgument::REQUIRED, 'The full name to the repository, eg symfony/symfony-docs');
41+
}
42+
43+
protected function execute(InputInterface $input, OutputInterface $output)
44+
{
45+
/** @var string $repositoryName */
46+
$repositoryName = $input->getArgument('repository');
47+
$repository = $this->repositoryProvider->getRepository($repositoryName);
48+
if (null === $repository) {
49+
$output->writeln('Repository not configured');
50+
51+
return 1;
52+
}
53+
54+
$notUpdatedAfter = new \DateTimeImmutable('-12months');
55+
$issues = $this->issueApi->findStaleIssues($repository, $notUpdatedAfter);
56+
57+
foreach ($issues as $issue) {
58+
$comment = $this->commentGenerator->getComment($this->extractType($issue));
59+
$this->issueApi->commentOnIssue($repository, $issue['number'], $comment);
60+
61+
// add a scheduled task to process this issue again after 2 weeks
62+
$this->scheduler->runLater($repository, $issue['number'], Task::ACTION_CLOSE_STALE, new \DateTimeImmutable('+2weeks'));
63+
}
64+
65+
return 0;
66+
}
67+
68+
/**
69+
* Extract type form issue array. Make sure we priorities labels if there are
70+
* more than one type defined.
71+
*/
72+
private function extractType(array $issue)
73+
{
74+
$types = [
75+
IssueType::FEATURE => false,
76+
IssueType::BUG => false,
77+
IssueType::RFC => false,
78+
];
79+
80+
foreach ($issue['labels'] as $label) {
81+
if (isset($types[$label['name']])) {
82+
$types[$label['name']] = true;
83+
}
84+
}
85+
86+
foreach ($types as $type => $exists) {
87+
if ($exists) {
88+
return $type;
89+
}
90+
}
91+
92+
return IssueType::UNKNOWN;
93+
}
94+
}

src/Service/RepositoryProvider.php

+3
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)
+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Service;
6+
7+
use App\Api\Issue\IssueType;
8+
9+
/**
10+
* @author Tobias Nyholm <[email protected]>
11+
*/
12+
class StaleIssueCommentGenerator
13+
{
14+
/**
15+
* Get a comment to say: "I'm closing this now".
16+
*/
17+
public function getClosingComment(): string
18+
{
19+
return <<<TXT
20+
Hey,
21+
22+
I didn't hear anything so I'm going to close it. Feel free to comment if this is still relevant, I can always reopen!
23+
TXT;
24+
}
25+
26+
/**
27+
* Get a comment that encurage users to reply or close the issue themselves.
28+
*
29+
* @param string $type Valid types are IssueType::*
30+
*/
31+
public function getComment(string $type): string
32+
{
33+
switch ($type) {
34+
case IssueType::BUG:
35+
return $this->bug();
36+
case IssueType::FEATURE:
37+
case IssueType::RFC:
38+
return $this->feature();
39+
default:
40+
return $this->unknown();
41+
}
42+
}
43+
44+
private function bug(): string
45+
{
46+
return <<<TXT
47+
Hey, thanks for your report!
48+
There has not been a lot of activity here for a while. Is this bug still relevant? Have you managed to find a workaround?
49+
TXT;
50+
}
51+
52+
private function feature(): string
53+
{
54+
return <<<TXT
55+
Thank you for this suggestion.
56+
There has not been a lot of activity here for a while. Would you still like to see this feature?
57+
TXT;
58+
}
59+
60+
private function unknown(): string
61+
{
62+
return <<<TXT
63+
Thank you for this issue.
64+
There has not been a lot of activity here for a while. Has this been resolved?
65+
TXT;
66+
}
67+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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+
use App\Service\StaleIssueCommentGenerator;
12+
13+
/**
14+
* @author Tobias Nyholm <[email protected]>
15+
*/
16+
class CloseStaleIssuesHandler implements TaskHandlerInterface
17+
{
18+
private $issueApi;
19+
private $repositoryProvider;
20+
private $labelApi;
21+
private $commentGenerator;
22+
23+
public function __construct(LabelApi $labelApi, IssueApi $issueApi, RepositoryProvider $repositoryProvider, StaleIssueCommentGenerator $commentGenerator)
24+
{
25+
$this->issueApi = $issueApi;
26+
$this->repositoryProvider = $repositoryProvider;
27+
$this->labelApi = $labelApi;
28+
$this->commentGenerator = $commentGenerator;
29+
}
30+
31+
/**
32+
* Close the issue if the last comment was made by the bot and if "Keep open" label does not exist.
33+
*/
34+
public function handle(Task $task): void
35+
{
36+
if (null === $repository = $this->repositoryProvider->getRepository($task->getRepositoryFullName())) {
37+
return;
38+
}
39+
$labels = $this->labelApi->getIssueLabels($task->getNumber(), $repository);
40+
if (in_array('Keep open', $labels)) {
41+
return;
42+
}
43+
44+
if ($this->issueApi->lastCommentWasMadeByBot($repository, $task->getNumber())) {
45+
$this->issueApi->commentOnIssue($repository, $task->getNumber(), $this->commentGenerator->getClosingComment());
46+
$this->issueApi->close($repository, $task->getNumber());
47+
}
48+
}
49+
50+
public function supports(Task $task): bool
51+
{
52+
return Task::ACTION_CLOSE_STALE === $task->getAction();
53+
}
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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\StaleIssueCommentGenerator;
12+
use App\Service\TaskHandler\CloseStaleIssuesHandler;
13+
use PHPUnit\Framework\TestCase;
14+
15+
class CloseStaleIssuesHandlerTest extends TestCase
16+
{
17+
public function testHandleKeepOpen()
18+
{
19+
$labelApi = $this->getMockBuilder(NullLabelApi::class)
20+
->disableOriginalConstructor()
21+
->setMethods(['getIssueLabels', 'lastCommentWasMadeByBot'])
22+
->getMock();
23+
$labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug', 'Keep open']);
24+
25+
$issueApi = $this->getMockBuilder(NullIssueApi::class)
26+
->disableOriginalConstructor()
27+
->setMethods(['close', 'lastCommentWasMadeByBot'])
28+
->getMock();
29+
$issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(true);
30+
$issueApi->expects($this->never())->method('close');
31+
32+
$repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
33+
34+
$handler = new CloseStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator());
35+
$handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable()));
36+
}
37+
38+
public function testHandleComments()
39+
{
40+
$labelApi = $this->getMockBuilder(NullLabelApi::class)
41+
->disableOriginalConstructor()
42+
->setMethods(['getIssueLabels', 'lastCommentWasMadeByBot'])
43+
->getMock();
44+
$labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug']);
45+
46+
$issueApi = $this->getMockBuilder(NullIssueApi::class)
47+
->disableOriginalConstructor()
48+
->setMethods(['close', 'lastCommentWasMadeByBot'])
49+
->getMock();
50+
$issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(false);
51+
$issueApi->expects($this->never())->method('close');
52+
53+
$repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
54+
55+
$handler = new CloseStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator());
56+
$handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable()));
57+
}
58+
59+
public function testHandleStale()
60+
{
61+
$labelApi = $this->getMockBuilder(NullLabelApi::class)
62+
->disableOriginalConstructor()
63+
->setMethods(['getIssueLabels'])
64+
->getMock();
65+
$labelApi->expects($this->any())->method('getIssueLabels')->willReturn(['Bug']);
66+
67+
$issueApi = $this->getMockBuilder(NullIssueApi::class)
68+
->disableOriginalConstructor()
69+
->setMethods(['close', 'lastCommentWasMadeByBot'])
70+
->getMock();
71+
$issueApi->expects($this->any())->method('lastCommentWasMadeByBot')->willReturn(true);
72+
$issueApi->expects($this->once())->method('close');
73+
74+
$repoProvider = new RepositoryProvider(['carsonbot-playground/symfony' => []]);
75+
76+
$handler = new CloseStaleIssuesHandler($labelApi, $issueApi, $repoProvider, new StaleIssueCommentGenerator());
77+
$handler->handle(new Task('carsonbot-playground/symfony', 4711, Task::ACTION_CLOSE_STALE, new \DateTimeImmutable()));
78+
}
79+
}

0 commit comments

Comments
 (0)