At Spatie we have over 180 public repositories. Some of our packages have become quite popular. We're very grateful that many of our users open up issues and PRs to ask questions, notify us of problems and try to solve those problems, ...

Most of these issues and PRs are handled by our team. But sometimes those issues and PRs become stale. The original author may not respond anymore, discussions maybe end up dead, or sometimes the issue isn't important enough for us to invest time in. Keeping track of those stale issue and PRs manually and close them after a while takes some efforts and is not really interesting.

That's why we created a bot that can automatically close stale issues and PRs. Here's a screenshot of the bot in action.

bot closing an issue

In this post I'd like to share the code of that bot.

Show me the code #

At our company we have a bot that lives in our Slack. It's powered by Marcel Pociot's excellent BotMan package. It can do a lot of useful things for our team. The auto-closing GitHub issues and PRs part of the code isn't really leveraging any BotMan specific functionality. Because there's no interactivity involved the auto-closing is done with a simple artisan command.

To communicate with GitHub we use the excellent knplabs/github-api package. This is the service provider that bootstraps that package.

namespace App\Providers;

use Github\Client;
use App\Services\GitHub\GitHub;
use Illuminate\Support\ServiceProvider;

class GitHubServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->singleton(Client::class, function () {
            $client = new Client();

            $client->authenticate(config('services.github.token'), null, Client::AUTH_HTTP_TOKEN);

            return $client;
        });

        $this->app->singleton(GitHub::class, function () {
            $client = app(Client::class);

            return new GitHub($client);
        });
    }
}

config('services.github.token') contains a token that was created on GitHub using a new dedicated account for our bot.

To easily work with the API and to automatically page results I created a thin wrapper around the it.

namespace App\Services\GitHub;

use Github\Client;
use Github\ResultPager;
use Illuminate\Support\Collection;

class GitHub
{
    /** @var \Github\Client */
    protected $client;

    /** @var \Github\ResultPager */
    protected $paginator;

    public function __construct(Client $client)
    {
        $this->client = $client;

        $this->paginator = new ResultPager($client);
    }

    public function search(string $for, string $query): Collection
    {
        $api = $this->client->api('search');

        $repos = $this->paginator->fetchAll($api, $for, [$query]);

        return collect($repos);
    }
}

Here's the actual artisan command that closes the issues and PRs. It will close each issue and PR that hasn't been in active in that past 6 months. If the issue / PR has a tag enhancement, help wanted or bug we'll keep it open. This command is scheduled to run daily.

namespace App\Console\Commands;

use App\Services\GitHub\Issue;
use App\Services\GitHub\GitHub;
use Illuminate\Console\Command;

class CloseInactiveGithubIssuesAndPRs extends Command
{
    protected $signature = 'bot:close-inactive-github-issues-and-prs';

    protected $description = 'Close inactive GitHub issues and PRs';

    /** @var \App\Services\GitHub */
    protected $gitHub;

    public function __construct(GitHub $gitHub)
    {
        parent::__construct();

        $this->gitHub = $gitHub;
    }

    public function handle()
    {
        $this->info('Start closing issues and PRs...');

        $sixMonthsAgo = now()->subMonths(6);

        $this->gitHub
            ->search('issues', "org:spatie is:public state:open updated:<{$sixMonthsAgo->format('Y-m-d')} ")
            ->map(function (array $issueAttributes) {
                return Issue::create($issueAttributes);
            })
            ->reject(function (Issue $issue) {
                return $issue->hasLabel(['enhancement', 'help wanted', 'bug']);
            })
            ->each(function (Issue $issue) {
                $issue->close('Dear contributor, ' . PHP_EOL . PHP_EOL . "because this {$issue->type()} seems to be inactive for quite some time now, I've automatically closed it. If you feel this {$issue->type()} deserves some attention from my human colleagues feel free to reopen it." );

                $this->comment("Closed {$issue->url()}");
            });

        $this->info('All done');
    }
}

You'll notice that we'll map the response from GitHub to Issue objects so we can handle it more easily. GitHub considers a PR. just another type of issue. Here's the code of the Issue class.

namespace App\Services\GitHub;

use Github\Client;

class Issue
{
    /** @var array */
    protected $issueAttributes;

    /** @var \App\Services\GitHub\GitHub */
    protected $gitHub;

    public static function create(array $issueAttributes): self
    {
        return app(static::class)->setAttributes($issueAttributes);
    }

    public function __construct(Client $gitHub)
    {
        $this->gitHub = $gitHub;
    }

    public function setAttributes(array $issueAttributes)
    {
        $this->issueAttributes = $issueAttributes;

        return $this;
    }

    public function number(): int
    {
        return $this->issueAttributes['number'];
    }

    public function repositoryName(): string
    {
        return array_reverse(explode('/', $this->issueAttributes['repository_url']))[0];
    }

    /**
     * @param string|array $searchLabels
     *
     * @return bool
     */
    public function hasLabel($searchLabels): bool
    {
        $searchLabels = array_wrap($searchLabels);

        $foundLabels = array_intersect($this->labelNames(), $searchLabels);

        return count($foundLabels) > 0;
    }

    public function labelNames(): array
    {
        return collect($this->issueAttributes['labels'])->pluck('name')->values()->toArray();
    }

    public function type(): string
    {
        return array_has($this->issueAttributes, 'pull_request')
            ? 'pull request'
            : 'issue';
    }

    public function url(): string
    {
        return $this->issueAttributes['html_url'];
    }

    public function close(string $message)
    {
        $this->gitHub
            ->api('issue')
            ->comments()
            ->create('spatie', $this->repositoryName(), $this->number(), ['body' => $message]);

        $this->gitHub
            ->api('issue')
            ->update('spatie', $this->repositoryName(), $this->number(), ['state' => 'closed']);
    }
}

In closing #

I hope you've enjoying this little tour of the code behind our auto-closing bot. It'll be active across all our public repos.