Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Comment on stale issues #109

Merged
merged 1 commit into from
Dec 18, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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:ping-stale symfony/symfony

stale_issues_docs:
spec: '48 12 * * *'
cmd: croncape bin/console app:issue:ping-stale symfony/symfony-docs

relationships:
database: "mydatabase:postgresql"
3 changes: 3 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ parameters:
- 'App\Subscriber\MilestoneNewPRSubscriber'
- 'App\Subscriber\WelcomeFirstTimeContributorSubscriber'
- 'App\Subscriber\CloseDraftPRSubscriber'
- 'App\Subscriber\RemoveStaledLabelOnCommentSubscriber'
secret: '%env(SYMFONY_SECRET)%'

symfony/symfony-docs:
Expand All @@ -23,6 +24,7 @@ parameters:
- 'App\Subscriber\BugLabelNewIssueSubscriber'
- 'App\Subscriber\AutoLabelFromContentSubscriber'
- 'subscriber.symfony_docs.milestone'
- 'App\Subscriber\RemoveStaledLabelOnCommentSubscriber'
secret: '%env(SYMFONY_DOCS_SECRET)%'

# used in a functional test
Expand All @@ -38,6 +40,7 @@ parameters:
- 'App\Subscriber\MilestoneNewPRSubscriber'
- 'App\Subscriber\WelcomeFirstTimeContributorSubscriber'
- 'App\Subscriber\CloseDraftPRSubscriber'
- 'App\Subscriber\RemoveStaledLabelOnCommentSubscriber'

services:
_defaults:
Expand Down
19 changes: 17 additions & 2 deletions src/Api/Issue/GithubIssueApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ public function open(Repository $repository, string $title, string $body, array
];

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

Expand All @@ -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);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is not using pagination. We will only fetch 100 comments. That means that we will never auto close issues with more that 100 comments.

Im fine with that atm.

$lastComment = $allComments[count($allComments) - 1] ?? [];

return $this->botUsername === ($lastComment['user']['login'] ?? null);
}

public function show(Repository $repository, $issueNumber): array
{
return $this->issueApi->show($repository->getVendor(), $repository->getName(), $issueNumber);
Expand All @@ -66,4 +74,11 @@ public function commentOnIssue(Repository $repository, $issueNumber, string $com
['body' => $commentBody]
);
}

public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUpdateAfter): array
{
$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'] ?? [];
}
}
4 changes: 4 additions & 0 deletions src/Api/Issue/IssueApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public function show(Repository $repository, $issueNumber): array;

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

public function lastCommentWasMadeByBot(Repository $repository, $number): bool;

public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUpdateAfter): array;

/**
* Close an issue or a pull request.
*/
Expand Down
13 changes: 13 additions & 0 deletions src/Api/Issue/IssueType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace App\Api\Issue;

class IssueType
{
public const BUG = 'Bug';
public const FEATURE = 'Feature';
public const UNKNOWN = 'Unknown';
public const RFC = 'RFC';
}
10 changes: 10 additions & 0 deletions src/Api/Issue/NullIssueApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ public function commentOnIssue(Repository $repository, $issueNumber, string $com
{
}

public function lastCommentWasMadeByBot(Repository $repository, $number): bool
{
return false;
}

public function findStaleIssues(Repository $repository, \DateTimeImmutable $noUpdateAfter): array
{
return [];
}

public function close(Repository $repository, $issueNumber)
{
}
Expand Down
113 changes: 113 additions & 0 deletions src/Command/PingStaleIssuesCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

namespace App\Command;

use App\Api\Issue\IssueApi;
use App\Api\Issue\IssueType;
use App\Api\Label\LabelApi;
use App\Entity\Task;
use App\Service\RepositoryProvider;
use App\Service\StaleIssueCommentGenerator;
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\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
* Close issues not been updated in a long while.
*
* @author Tobias Nyholm <[email protected]>
*/
class PingStaleIssuesCommand extends Command
{
public const STALE_IF_NOT_UPDATED_SINCE = '-12months';
public const MESSAGE_TWO_AFTER = '+2weeks';
public const MESSAGE_THREE_AND_CLOSE_AFTER = '+2weeks';

protected static $defaultName = 'app:issue:ping-stale';

private $repositoryProvider;
private $issueApi;
private $scheduler;
private $commentGenerator;
private $labelApi;

public function __construct(RepositoryProvider $repositoryProvider, IssueApi $issueApi, TaskScheduler $scheduler, StaleIssueCommentGenerator $commentGenerator, LabelApi $labelApi)
{
parent::__construct();
$this->repositoryProvider = $repositoryProvider;
$this->issueApi = $issueApi;
$this->scheduler = $scheduler;
$this->commentGenerator = $commentGenerator;
$this->labelApi = $labelApi;
}

protected function configure()
{
$this->addArgument('repository', InputArgument::REQUIRED, 'The full name to the repository, eg symfony/symfony-docs');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, but could we add a --dry-run option? And also write output in the command? For example:

Adding label and comment to issue #555 on symfony/symfony

And, of course, the --dry-run would make no changes. It would make me a bit more comfortable, as we could deploy, then run the command manually with --dry-run and make sure there are no surprises (like WAY too many issues being commented on, due to some bug).

Thanks!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ´--dry-run´ option is added.

It will show you 100 issues on Symfony/symfony on the first run. We do not use pagination because it isn't really needed.

Here is a link to the search: https://github.com/symfony/symfony/issues?q=is%3Aissue+-linked%3Apr+-label%3A%22Keep+open%22+is%3Aopen+updated%3A%3C2019-12-18

$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do a test search without making any comments or changes');
}

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(self::STALE_IF_NOT_UPDATED_SINCE);
$issues = $this->issueApi->findStaleIssues($repository, $notUpdatedAfter);

if ($input->getOption('dry-run')) {
foreach ($issues as $issue) {
$output->writeln(sprintf('Marking issue #%s as "Staled". Link https://github.com/%s/issues/%s', $issue['number'], $repository->getFullName(), $issue['number']));
}

return 0;
}

foreach ($issues as $issue) {
$comment = $this->commentGenerator->getComment($this->extractType($issue));
$this->issueApi->commentOnIssue($repository, $issue['number'], $comment);
$this->labelApi->addIssueLabel($issue['number'], 'Staled', $repository);

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

return 0;
}

/**
* Extract type from issue array. Make sure we priorities labels if there are
* more than one type defined.
*/
private function extractType(array $issue)
{
$types = [
IssueType::FEATURE => false,
IssueType::BUG => false,
IssueType::RFC => false,
];

foreach ($issue['labels'] as $label) {
if (isset($types[$label['name']])) {
$types[$label['name']] = true;
}
}

foreach ($types as $type => $exists) {
if ($exists) {
return $type;
}
}

return IssueType::UNKNOWN;
}
}
1 change: 1 addition & 0 deletions src/Entity/Task.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class Task
{
const ACTION_CLOSE_STALE = 1;
const ACTION_CLOSE_DRAFT = 2;
const ACTION_INFORM_CLOSE_STALE = 3;

/**
* @var int
Expand Down
3 changes: 3 additions & 0 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 Down
84 changes: 84 additions & 0 deletions src/Service/StaleIssueCommentGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace App\Service;

use App\Api\Issue\IssueType;

/**
* @author Tobias Nyholm <[email protected]>
*/
class StaleIssueCommentGenerator
{
/**
* Get a comment to say: "I will close this soon".
*/
public function getInformAboutClosingComment(): string
{
$messages = [
'Hello? This issue is about to be closed if nobody replies.',
'Friendly ping? Should this still be open? I will close if I don\'t hear anything.',
'Could I get a reply or should I close this?',
'Just a quick reminder to make a comment on this. If I don\'t hear anything I\'ll close this.',
'Friendly reminder that this issue exists. If I don\'t hear anything I\'ll close this.',
'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',
];

return $messages[array_rand($messages)];
}

/**
* Get a comment to say: "I'm closing this now".
*/
public function getClosingComment(): string
{
return <<<TXT
Hey,

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!
TXT;
}

/**
* Get a comment that encourage users to reply or close the issue themselves.
*
* @param string $type Valid types are IssueType::*
*/
public function getComment(string $type): string
{
switch ($type) {
case IssueType::BUG:
return $this->bug();
case IssueType::FEATURE:
case IssueType::RFC:
return $this->feature();
default:
return $this->unknown();
}
}

private function bug(): string
{
return <<<TXT
Hey, thanks for your report!
There has not been a lot of activity here for a while. Is this bug still relevant? Have you managed to find a workaround?
TXT;
}

private function feature(): string
{
return <<<TXT
Thank you for this suggestion.
There has not been a lot of activity here for a while. Would you still like to see this feature?
TXT;
}

private function unknown(): string
{
return <<<TXT
Thank you for this issue.
There has not been a lot of activity here for a while. Has this been resolved?
TXT;
}
}
58 changes: 58 additions & 0 deletions src/Service/TaskHandler/CloseStaleIssuesHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?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;
use App\Service\StaleIssueCommentGenerator;

/**
* @author Tobias Nyholm <[email protected]>
*/
class CloseStaleIssuesHandler implements TaskHandlerInterface
{
private $issueApi;
private $repositoryProvider;
private $labelApi;
private $commentGenerator;

public function __construct(LabelApi $labelApi, IssueApi $issueApi, RepositoryProvider $repositoryProvider, StaleIssueCommentGenerator $commentGenerator)
{
$this->issueApi = $issueApi;
$this->repositoryProvider = $repositoryProvider;
$this->labelApi = $labelApi;
$this->commentGenerator = $commentGenerator;
}

/**
* 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)) {
$this->labelApi->removeIssueLabel($task->getNumber(), 'Staled', $repository);

return;
}

if ($this->issueApi->lastCommentWasMadeByBot($repository, $task->getNumber())) {
$this->issueApi->commentOnIssue($repository, $task->getNumber(), $this->commentGenerator->getClosingComment());
$this->issueApi->close($repository, $task->getNumber());
} else {
$this->labelApi->removeIssueLabel($task->getNumber(), 'Staled', $repository);
}
}

public function supports(Task $task): bool
{
return Task::ACTION_CLOSE_STALE === $task->getAction();
}
}
Loading