Skip to content

Commit

Permalink
feature #109 Comment on stale issues (Nyholm)
Browse files Browse the repository at this point in the history
This PR was squashed before being merged into the master branch.

Discussion
----------

Comment on stale issues

This PR adds a `PingStaleIssuesCommand`. It will look for old inactive issues and start a process with them.
1. Bot will make a comment to encourage activity and add label "Staled".
2. Bot will make a comment to inform the issue will be closed
3. Bot will close the issue.
The process can be interrupted with anyone making a comment on the issue or the "Keep open" label is added.

The exact times between the steps described above is defined as constants in `PingStaleIssuesCommand`.

Commits
-------

9de3045 Comment on stale issues
  • Loading branch information
weaverryan committed Dec 18, 2020
2 parents cb07254 + 9de3045 commit 1240694
Show file tree
Hide file tree
Showing 16 changed files with 685 additions and 2 deletions.
8 changes: 8 additions & 0 deletions .symfony.cloud.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,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);
$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');
$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

0 comments on commit 1240694

Please sign in to comment.