Skip to content

Commit 9de3045

Browse files
Nyholmweaverryan
authored andcommitted
Comment on stale issues
1 parent 4ff0fd6 commit 9de3045

16 files changed

+685
-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:ping-stale symfony/symfony
44+
45+
stale_issues_docs:
46+
spec: '48 12 * * *'
47+
cmd: croncape bin/console app:issue:ping-stale symfony/symfony-docs
48+
4149
relationships:
4250
database: "mydatabase:postgresql"

config/services.yaml

+3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ parameters:
1111
- 'App\Subscriber\MilestoneNewPRSubscriber'
1212
- 'App\Subscriber\WelcomeFirstTimeContributorSubscriber'
1313
- 'App\Subscriber\CloseDraftPRSubscriber'
14+
- 'App\Subscriber\RemoveStaledLabelOnCommentSubscriber'
1415
secret: '%env(SYMFONY_SECRET)%'
1516

1617
symfony/symfony-docs:
@@ -23,6 +24,7 @@ parameters:
2324
- 'App\Subscriber\BugLabelNewIssueSubscriber'
2425
- 'App\Subscriber\AutoLabelFromContentSubscriber'
2526
- 'subscriber.symfony_docs.milestone'
27+
- 'App\Subscriber\RemoveStaledLabelOnCommentSubscriber'
2628
secret: '%env(SYMFONY_DOCS_SECRET)%'
2729

2830
# used in a functional test
@@ -38,6 +40,7 @@ parameters:
3840
- 'App\Subscriber\MilestoneNewPRSubscriber'
3941
- 'App\Subscriber\WelcomeFirstTimeContributorSubscriber'
4042
- 'App\Subscriber\CloseDraftPRSubscriber'
43+
- 'App\Subscriber\RemoveStaledLabelOnCommentSubscriber'
4144

4245
services:
4346
_defaults:

src/Api/Issue/GithubIssueApi.php

+17-2
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ public function open(Repository $repository, string $title, string $body, array
3131
];
3232

3333
$issueNumber = null;
34-
$exitingIssues = $this->searchApi->issues(sprintf('repo:%s "%s" is:open author:%s', $repository->getFullName(), $title, $this->botUsername));
35-
foreach ($exitingIssues['items'] ?? [] as $issue) {
34+
$existingIssues = $this->searchApi->issues(sprintf('repo:%s "%s" is:open author:%s', $repository->getFullName(), $title, $this->botUsername));
35+
foreach ($existingIssues['items'] ?? [] as $issue) {
3636
$issueNumber = $issue['number'];
3737
}
3838

@@ -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
}
+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
namespace App\Command;
4+
5+
use App\Api\Issue\IssueApi;
6+
use App\Api\Issue\IssueType;
7+
use App\Api\Label\LabelApi;
8+
use App\Entity\Task;
9+
use App\Service\RepositoryProvider;
10+
use App\Service\StaleIssueCommentGenerator;
11+
use App\Service\TaskScheduler;
12+
use Symfony\Component\Console\Command\Command;
13+
use Symfony\Component\Console\Input\InputArgument;
14+
use Symfony\Component\Console\Input\InputInterface;
15+
use Symfony\Component\Console\Input\InputOption;
16+
use Symfony\Component\Console\Output\OutputInterface;
17+
18+
/**
19+
* Close issues not been updated in a long while.
20+
*
21+
* @author Tobias Nyholm <[email protected]>
22+
*/
23+
class PingStaleIssuesCommand extends Command
24+
{
25+
public const STALE_IF_NOT_UPDATED_SINCE = '-12months';
26+
public const MESSAGE_TWO_AFTER = '+2weeks';
27+
public const MESSAGE_THREE_AND_CLOSE_AFTER = '+2weeks';
28+
29+
protected static $defaultName = 'app:issue:ping-stale';
30+
31+
private $repositoryProvider;
32+
private $issueApi;
33+
private $scheduler;
34+
private $commentGenerator;
35+
private $labelApi;
36+
37+
public function __construct(RepositoryProvider $repositoryProvider, IssueApi $issueApi, TaskScheduler $scheduler, StaleIssueCommentGenerator $commentGenerator, LabelApi $labelApi)
38+
{
39+
parent::__construct();
40+
$this->repositoryProvider = $repositoryProvider;
41+
$this->issueApi = $issueApi;
42+
$this->scheduler = $scheduler;
43+
$this->commentGenerator = $commentGenerator;
44+
$this->labelApi = $labelApi;
45+
}
46+
47+
protected function configure()
48+
{
49+
$this->addArgument('repository', InputArgument::REQUIRED, 'The full name to the repository, eg symfony/symfony-docs');
50+
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do a test search without making any comments or changes');
51+
}
52+
53+
protected function execute(InputInterface $input, OutputInterface $output)
54+
{
55+
/** @var string $repositoryName */
56+
$repositoryName = $input->getArgument('repository');
57+
$repository = $this->repositoryProvider->getRepository($repositoryName);
58+
if (null === $repository) {
59+
$output->writeln('Repository not configured');
60+
61+
return 1;
62+
}
63+
64+
$notUpdatedAfter = new \DateTimeImmutable(self::STALE_IF_NOT_UPDATED_SINCE);
65+
$issues = $this->issueApi->findStaleIssues($repository, $notUpdatedAfter);
66+
67+
if ($input->getOption('dry-run')) {
68+
foreach ($issues as $issue) {
69+
$output->writeln(sprintf('Marking issue #%s as "Staled". Link https://github.com/%s/issues/%s', $issue['number'], $repository->getFullName(), $issue['number']));
70+
}
71+
72+
return 0;
73+
}
74+
75+
foreach ($issues as $issue) {
76+
$comment = $this->commentGenerator->getComment($this->extractType($issue));
77+
$this->issueApi->commentOnIssue($repository, $issue['number'], $comment);
78+
$this->labelApi->addIssueLabel($issue['number'], 'Staled', $repository);
79+
80+
// add a scheduled task to process this issue again after 2 weeks
81+
$this->scheduler->runLater($repository, $issue['number'], Task::ACTION_INFORM_CLOSE_STALE, new \DateTimeImmutable(self::MESSAGE_TWO_AFTER));
82+
}
83+
84+
return 0;
85+
}
86+
87+
/**
88+
* Extract type from issue array. Make sure we priorities labels if there are
89+
* more than one type defined.
90+
*/
91+
private function extractType(array $issue)
92+
{
93+
$types = [
94+
IssueType::FEATURE => false,
95+
IssueType::BUG => false,
96+
IssueType::RFC => false,
97+
];
98+
99+
foreach ($issue['labels'] as $label) {
100+
if (isset($types[$label['name']])) {
101+
$types[$label['name']] = true;
102+
}
103+
}
104+
105+
foreach ($types as $type => $exists) {
106+
if ($exists) {
107+
return $type;
108+
}
109+
}
110+
111+
return IssueType::UNKNOWN;
112+
}
113+
}

src/Entity/Task.php

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Task
1717
{
1818
const ACTION_CLOSE_STALE = 1;
1919
const ACTION_CLOSE_DRAFT = 2;
20+
const ACTION_INFORM_CLOSE_STALE = 3;
2021

2122
/**
2223
* @var int

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)
+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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 will close this soon".
16+
*/
17+
public function getInformAboutClosingComment(): string
18+
{
19+
$messages = [
20+
'Hello? This issue is about to be closed if nobody replies.',
21+
'Friendly ping? Should this still be open? I will close if I don\'t hear anything.',
22+
'Could I get a reply or should I close this?',
23+
'Just a quick reminder to make a comment on this. If I don\'t hear anything I\'ll close this.',
24+
'Friendly reminder that this issue exists. If I don\'t hear anything I\'ll close this.',
25+
'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',
26+
];
27+
28+
return $messages[array_rand($messages)];
29+
}
30+
31+
/**
32+
* Get a comment to say: "I'm closing this now".
33+
*/
34+
public function getClosingComment(): string
35+
{
36+
return <<<TXT
37+
Hey,
38+
39+
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!
40+
TXT;
41+
}
42+
43+
/**
44+
* Get a comment that encourage users to reply or close the issue themselves.
45+
*
46+
* @param string $type Valid types are IssueType::*
47+
*/
48+
public function getComment(string $type): string
49+
{
50+
switch ($type) {
51+
case IssueType::BUG:
52+
return $this->bug();
53+
case IssueType::FEATURE:
54+
case IssueType::RFC:
55+
return $this->feature();
56+
default:
57+
return $this->unknown();
58+
}
59+
}
60+
61+
private function bug(): string
62+
{
63+
return <<<TXT
64+
Hey, thanks for your report!
65+
There has not been a lot of activity here for a while. Is this bug still relevant? Have you managed to find a workaround?
66+
TXT;
67+
}
68+
69+
private function feature(): string
70+
{
71+
return <<<TXT
72+
Thank you for this suggestion.
73+
There has not been a lot of activity here for a while. Would you still like to see this feature?
74+
TXT;
75+
}
76+
77+
private function unknown(): string
78+
{
79+
return <<<TXT
80+
Thank you for this issue.
81+
There has not been a lot of activity here for a while. Has this been resolved?
82+
TXT;
83+
}
84+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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+
$this->labelApi->removeIssueLabel($task->getNumber(), 'Staled', $repository);
42+
43+
return;
44+
}
45+
46+
if ($this->issueApi->lastCommentWasMadeByBot($repository, $task->getNumber())) {
47+
$this->issueApi->commentOnIssue($repository, $task->getNumber(), $this->commentGenerator->getClosingComment());
48+
$this->issueApi->close($repository, $task->getNumber());
49+
} else {
50+
$this->labelApi->removeIssueLabel($task->getNumber(), 'Staled', $repository);
51+
}
52+
}
53+
54+
public function supports(Task $task): bool
55+
{
56+
return Task::ACTION_CLOSE_STALE === $task->getAction();
57+
}
58+
}

0 commit comments

Comments
 (0)