├── .env.example ├── .github ├── renovate.json ├── stale.yml └── workflows │ ├── ci.yml │ └── stats.yml ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app.json ├── docs └── deploy.md ├── index.js ├── lib ├── schema.js └── stale.js ├── newrelic.js ├── package-lock.json ├── package.json └── test ├── schema.test.js └── stale.test.js /.env.example: -------------------------------------------------------------------------------- 1 | # The ID of your GitHub App 2 | APP_ID= 3 | WEBHOOK_SECRET=development 4 | 5 | # Uncomment this to get verbose logging; use `info` to show less 6 | # LOG_LEVEL=trace 7 | 8 | # Go to https://smee.io/new set this to the URL that you are redirected to. 9 | # WEBHOOK_PROXY_URL= 10 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>probot/.github" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | _extends: .github 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [8.x, 10.x, 12.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: npm ci, and test 21 | run: | 22 | npm ci 23 | npm test 24 | -------------------------------------------------------------------------------- /.github/workflows/stats.yml: -------------------------------------------------------------------------------- 1 | on: 2 | schedule: 3 | # https://crontab.guru/once-a-day 4 | - cron: 0 0 * * * 5 | workflow_dispatch: {} 6 | 7 | name: Stats 8 | jobs: 9 | stats: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: gr2m/app-stats-action@v1.x 13 | id: stats 14 | with: 15 | id: ${{ secrets.STALE_APP_ID }} 16 | private_key: ${{ secrets.STALE_APP_PRIVATE_KEY }} 17 | - run: "echo installations: '${{ steps.stats.outputs.installations }}'" 18 | - run: "echo suspended: '${{ steps.stats.outputs.suspended_installations }}'" 19 | - run: "echo repositories: '${{ steps.stats.outputs.repositories }}'" 20 | - run: "echo most popular repositories: '${{ steps.stats.outputs.popular_repositories }}'" 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | npm-debug.log 4 | private-key.pem 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - 8 5 | notifications: 6 | disabled: true 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [![Build Status](https://travis-ci.org/probot/stale.svg?branch=master)](https://travis-ci.org/probot/stale) 4 | [![Codecov](https://img.shields.io/codecov/c/github/probot/probot.svg)](https://codecov.io/gh/probot/probot/) 5 | 6 | [code-of-conduct]: CODE_OF_CONDUCT.md 7 | [good-first-issue-search]: https://github.com/search?utf8=%E2%9C%93&q=topic%3Aprobot+topic%3Aprobot-app+good-first-issues%3A%3E0&type= 8 | 9 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 10 | 11 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 12 | 13 | Want to chat with Stale or Probot users and contributors? [Join us in Slack](https://probot-slackin.herokuapp.com/)! 14 | 15 | ## Issues and PRs 16 | 17 | If you have suggestions for how Stale could be improved, or want to report a bug, open an issue! We'd love all and any contributions. If you have questions, too, we'd love to hear them. 18 | 19 | We'd also love PRs. If you're thinking of a large PR, we advise opening up an issue first to talk about it, though! Look at the links below if you're not sure how to open a PR. 20 | 21 | ## Just starting out? Looking for how to help? 22 | 23 | Use [this search][good-first-issue-search] to find Probot apps that have issues marked with the `good-first-issue` label. 24 | 25 | ## Apps 26 | 27 | We have one app enabled on this repo: 28 | 29 | - [Stale](https://probot.github.io/apps/stale/): We use Stale (the app created in this repo) to ensure that conversations here remain relevant. This is for us, the maintainers, so that we don't feel like we've got hundreds of issues to deal with; if you still have an issue, please let us know! We don't want to close issues that are painful for you. Stale just helps us have a bit more breathing space by making sure issues don't pile up forever. 30 | 31 | If you're concerned about our apps or feel that they are insensitive in some way, please let us know. 32 | 33 | ## Resources 34 | 35 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 36 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 37 | - [GitHub Help](https://help.github.com) 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2017, Brandon Keepers 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Probot: Stale 2 | 3 | > A GitHub App built with [Probot](https://github.com/probot/probot) that closes abandoned Issues and Pull Requests after a period of inactivity. 4 | 5 | [![](https://cloud.githubusercontent.com/assets/173/23858697/4885f0d6-07cf-11e7-96ed-716948027bbc.png)](https://github.com/probot/demo/issues/2) 6 | 7 | Inspired by @parkr's [auto-reply](https://github.com/parkr/auto-reply#optional-mark-and-sweep-stale-issues) bot that runs @jekyllbot. 8 | 9 | ## 📯 The stale app is deprecated and this repository is no longer maintained 10 | 11 | Please use [the stale action](https://github.com/actions/stale) instead. 12 | 13 |
14 | See old Readme 15 | 16 | ## Usage 17 | 18 | 1. **[Configure the GitHub App](https://github.com/apps/stale)** 19 | 2. Create `.github/stale.yml` based on the following template. 20 | 3. It will start scanning for stale issues and/or pull requests within 24 hours. 21 | 22 | A `.github/stale.yml` file is required to enable the plugin. The file can be empty, or it can override any of these default settings: 23 | 24 | ```yml 25 | # Configuration for probot-stale - https://github.com/probot/stale 26 | 27 | # Number of days of inactivity before an Issue or Pull Request becomes stale 28 | daysUntilStale: 60 29 | 30 | # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. 31 | # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 32 | daysUntilClose: 7 33 | 34 | # Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) 35 | onlyLabels: [] 36 | 37 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 38 | exemptLabels: 39 | - pinned 40 | - security 41 | - "[Status] Maybe Later" 42 | 43 | # Set to true to ignore issues in a project (defaults to false) 44 | exemptProjects: false 45 | 46 | # Set to true to ignore issues in a milestone (defaults to false) 47 | exemptMilestones: false 48 | 49 | # Set to true to ignore issues with an assignee (defaults to false) 50 | exemptAssignees: false 51 | 52 | # Label to use when marking as stale 53 | staleLabel: wontfix 54 | 55 | # Comment to post when marking as stale. Set to `false` to disable 56 | markComment: > 57 | This issue has been automatically marked as stale because it has not had 58 | recent activity. It will be closed if no further activity occurs. Thank you 59 | for your contributions. 60 | 61 | # Comment to post when removing the stale label. 62 | # unmarkComment: > 63 | # Your comment here. 64 | 65 | # Comment to post when closing a stale Issue or Pull Request. 66 | # closeComment: > 67 | # Your comment here. 68 | 69 | # Limit the number of actions per hour, from 1-30. Default is 30 70 | limitPerRun: 30 71 | 72 | # Limit to only `issues` or `pulls` 73 | # only: issues 74 | 75 | # Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': 76 | # pulls: 77 | # daysUntilStale: 30 78 | # markComment: > 79 | # This pull request has been automatically marked as stale because it has not had 80 | # recent activity. It will be closed if no further activity occurs. Thank you 81 | # for your contributions. 82 | 83 | # issues: 84 | # exemptLabels: 85 | # - confirmed 86 | ``` 87 | 88 | ## How are issues and pull requests considered stale? 89 | 90 | The app uses GitHub's [updated](https://help.github.com/articles/searching-issues/#search-based-on-when-an-issue-or-pull-request-was-created-or-last-updated) search qualifier to determine staleness. Any change to an issue or pull request is considered an update, including comments, changing labels, applying or removing milestones, or pushing commits. 91 | 92 | An easy way to check and see which issues or pull requests will initially be marked as stale is to add the `updated` search qualifier to either the issue or pull request page filter for your repository: `updated:<2017-07-01`. Adjust the date to be 60 days ago (or whatever you set for `daysUntilStale`) to see which issues or pull requests will be marked. 93 | 94 | ## Why did only some issues and pull requests get marked stale? 95 | 96 | To avoid triggering abuse prevention mechanisms on GitHub, only 30 issues and pull requests will be marked or closed per hour. If your repository has more than that, it will just take a few hours or days to mark them all. 97 | 98 | ## How long will it take? 99 | 100 | The app runs on a scheduled basis and in batches in order to avoid hitting rate limit ceilings. 101 | 102 | This means that even after you initially install the GitHub configuration and add the `stale.yml` file, you may not see it act immediately. 103 | 104 | If the bot doesn't run within 24 hours of initial setup, feel free to [open an issue](https://github.com/probot/stale/issues/new) and we can investigate further. 105 | 106 | ## Is closing stale issues really a good idea? 107 | 108 | In an ideal world with infinite resources, there would be no need for this app. 109 | 110 | But in any successful software project, there's always more work to do than people to do it. As more and more work piles up, it becomes paralyzing. Just making decisions about what work should and shouldn't get done can exhaust all available resources. In the experience of the maintainers of this app—and the hundreds of other projects and organizations that use it—focusing on issues that are actively affecting humans is an effective method for prioritizing work. 111 | 112 | To some, a robot trying to close stale issues may seem inhospitable or offensive to contributors. But the alternative is to disrespect them by setting false expectations and implicitly ignoring their work. This app makes it explicit: if work is not progressing, then it's stale. A comment is all it takes to keep the conversation alive. 113 | 114 | ## Deployment 115 | 116 | See [docs/deploy.md](docs/deploy.md) if you would like to run your own instance of this plugin. 117 | 118 | ## Contribute 119 | 120 | If you have suggestions for how Stale could be improved, or want to report a bug, open an issue! We'd love all and any contributions. 121 | 122 | Note that all interactions fall under the [Probot Code of Conduct](https://github.com/probot/probot/blob/master/CODE_OF_CONDUCT.md). 123 | 124 | ## License 125 | 126 | [ISC](LICENSE) Copyright © 2017-2018 Brandon Keepers 127 | 128 |
129 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "PRIVATE_KEY": { 4 | "description": "the private key you downloaded when creating the GitHub App" 5 | }, 6 | "APP_ID": { 7 | "description": "the ID of your GitHub App" 8 | }, 9 | "WEBHOOK_SECRET": { 10 | "description": "the secret configured for your GitHub App" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docs/deploy.md: -------------------------------------------------------------------------------- 1 | # Deploying 2 | 3 | If you would like to run your own instance of this plugin, see the [docs for deploying plugins](https://github.com/probot/probot/blob/master/docs/deployment.md). 4 | 5 | This plugin requires these **Permissions & events** for the GitHub App: 6 | 7 | - Issues - **Read & Write** 8 | - [x] Check the box for **Issue comment** events 9 | - [x] Check the box for **Issues** events 10 | - Pull requests - **Read & Write** 11 | - [x] Check the box for **Pull request** events 12 | - [x] Check the box for **Pull request review** events 13 | - [x] Check the box for **Pull request review comment** events 14 | - Single File - **Read-only** 15 | - Path: `.github/stale.yml` 16 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('newrelic') 2 | 3 | const getConfig = require('probot-config') 4 | const createScheduler = require('probot-scheduler') 5 | const Stale = require('./lib/stale') 6 | 7 | module.exports = async app => { 8 | // Visit all repositories to mark and sweep stale issues 9 | const scheduler = createScheduler(app) 10 | 11 | // Unmark stale issues if a user comments 12 | const events = [ 13 | 'issue_comment', 14 | 'issues', 15 | 'pull_request', 16 | 'pull_request_review', 17 | 'pull_request_review_comment' 18 | ] 19 | 20 | app.on(events, unmark) 21 | app.on('schedule.repository', markAndSweep) 22 | 23 | async function unmark (context) { 24 | if (!context.isBot) { 25 | const stale = await forRepository(context) 26 | let issue = context.payload.issue || context.payload.pull_request 27 | const type = context.payload.issue ? 'issues' : 'pulls' 28 | 29 | // Some payloads don't include labels 30 | if (!issue.labels) { 31 | try { 32 | issue = (await context.github.issues.get(context.issue())).data 33 | } catch (error) { 34 | context.log('Issue not found') 35 | } 36 | } 37 | 38 | const staleLabelAdded = context.payload.action === 'labeled' && 39 | context.payload.label.name === stale.config.staleLabel 40 | 41 | if (stale.hasStaleLabel(type, issue) && issue.state !== 'closed' && !staleLabelAdded) { 42 | await stale.unmarkIssue(type, issue) 43 | } 44 | } 45 | } 46 | 47 | async function markAndSweep (context) { 48 | const stale = await forRepository(context) 49 | await stale.markAndSweep('pulls') 50 | await stale.markAndSweep('issues') 51 | } 52 | 53 | async function forRepository (context) { 54 | let config = await getConfig(context, 'stale.yml') 55 | 56 | if (!config) { 57 | scheduler.stop(context.payload.repository) 58 | // Don't actually perform for repository without a config 59 | config = { perform: false } 60 | } 61 | 62 | config = Object.assign(config, context.repo({ logger: app.log })) 63 | 64 | return new Stale(context.github, config) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/schema.js: -------------------------------------------------------------------------------- 1 | const Joi = require('@hapi/joi') 2 | 3 | const fields = { 4 | daysUntilStale: Joi.number() 5 | .description('Number of days of inactivity before an Issue or Pull Request becomes stale'), 6 | 7 | daysUntilClose: Joi.alternatives().try(Joi.number(), Joi.boolean().only(false)) 8 | .error(() => '"daysUntilClose" must be a number or false') 9 | .description('Number of days of inactivity before a stale Issue or Pull Request is closed. If disabled, issues still need to be closed manually, but will remain marked as stale.'), 10 | 11 | onlyLabels: Joi.alternatives().try(Joi.any().valid(null), Joi.array().single()) 12 | .description('Only issues or pull requests with all of these labels are checked for staleness. Set to `[]` to disable'), 13 | 14 | exemptLabels: Joi.alternatives().try(Joi.any().valid(null), Joi.array().single()) 15 | .description('Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable'), 16 | 17 | exemptProjects: Joi.boolean() 18 | .description('Set to true to ignore issues in a project (defaults to false)'), 19 | 20 | exemptMilestones: Joi.boolean() 21 | .description('Set to true to ignore issues in a milestone (defaults to false)'), 22 | 23 | exemptAssignees: Joi.boolean() 24 | .description('Set to true to ignore issues with an assignee (defaults to false)'), 25 | 26 | staleLabel: Joi.string() 27 | .description('Label to use when marking as stale'), 28 | 29 | markComment: Joi.alternatives().try(Joi.string(), Joi.any().only(false)) 30 | .error(() => '"markComment" must be a string or false') 31 | .description('Comment to post when marking as stale. Set to `false` to disable'), 32 | 33 | unmarkComment: Joi.alternatives().try(Joi.string(), Joi.boolean().only(false)) 34 | .error(() => '"unmarkComment" must be a string or false') 35 | .description('Comment to post when removing the stale label. Set to `false` to disable'), 36 | 37 | closeComment: Joi.alternatives().try(Joi.string(), Joi.boolean().only(false)) 38 | .error(() => '"closeComment" must be a string or false') 39 | .description('Comment to post when closing a stale Issue or Pull Request. Set to `false` to disable'), 40 | 41 | limitPerRun: Joi.number().integer().min(1).max(30) 42 | .error(() => '"limitPerRun" must be an integer between 1 and 30') 43 | .description('Limit the number of actions per hour, from 1-30. Default is 30') 44 | } 45 | 46 | const schema = Joi.object().keys({ 47 | daysUntilStale: fields.daysUntilStale.default(60), 48 | daysUntilClose: fields.daysUntilClose.default(7), 49 | onlyLabels: fields.onlyLabels.default([]), 50 | exemptLabels: fields.exemptLabels.default(['pinned', 'security']), 51 | exemptProjects: fields.exemptProjects.default(false), 52 | exemptMilestones: fields.exemptMilestones.default(false), 53 | exemptAssignees: fields.exemptMilestones.default(false), 54 | staleLabel: fields.staleLabel.default('wontfix'), 55 | markComment: fields.markComment.default( 56 | 'Is this still relevant? If so, what is blocking it? ' + 57 | 'Is there anything you can do to help move it forward?' + 58 | '\n\nThis issue has been automatically marked as stale ' + 59 | 'because it has not had recent activity. ' + 60 | 'It will be closed if no further activity occurs.' 61 | ), 62 | unmarkComment: fields.unmarkComment.default(false), 63 | closeComment: fields.closeComment.default(false), 64 | limitPerRun: fields.limitPerRun.default(30), 65 | perform: Joi.boolean().default(!process.env.DRY_RUN), 66 | only: Joi.any().valid('issues', 'pulls', null).description('Limit to only `issues` or `pulls`'), 67 | pulls: Joi.object().keys(fields), 68 | issues: Joi.object().keys(fields), 69 | _extends: Joi.string().description('Repository to extend settings from') 70 | }) 71 | 72 | module.exports = schema 73 | -------------------------------------------------------------------------------- /lib/stale.js: -------------------------------------------------------------------------------- 1 | const schema = require('./schema') 2 | const maxActionsPerRun = 30 3 | 4 | module.exports = class Stale { 5 | constructor (github, { owner, repo, logger = console, ...config }) { 6 | this.github = github 7 | this.logger = logger 8 | this.remainingActions = 0 9 | 10 | const { error, value } = schema.validate(config) 11 | 12 | this.config = value 13 | if (error) { 14 | // Report errors to sentry 15 | logger.warn({ err: new Error(error), owner, repo }, 'Invalid config') 16 | } 17 | 18 | Object.assign(this.config, { owner, repo }) 19 | } 20 | 21 | async markAndSweep (type) { 22 | const { only } = this.config 23 | if (only && only !== type) { 24 | return 25 | } 26 | if (!this.getConfigValue(type, 'perform')) { 27 | return 28 | } 29 | 30 | this.logger.info(this.config, `starting mark and sweep of ${type}`) 31 | 32 | const limitPerRun = this.getConfigValue(type, 'limitPerRun') || maxActionsPerRun 33 | this.remainingActions = Math.min(limitPerRun, maxActionsPerRun) 34 | 35 | await this.mark(type) 36 | await this.sweep(type) 37 | } 38 | 39 | async mark (type) { 40 | await this.ensureStaleLabelExists(type) 41 | 42 | const staleItems = (await this.getStale(type)).data.items 43 | 44 | await Promise.all( 45 | staleItems 46 | .filter(issue => !issue.locked && issue.state !== 'closed') 47 | .map(issue => this.markIssue(type, issue)) 48 | ) 49 | } 50 | 51 | async sweep (type) { 52 | const { owner, repo } = this.config 53 | const daysUntilClose = this.getConfigValue(type, 'daysUntilClose') 54 | 55 | if (daysUntilClose) { 56 | this.logger.trace({ owner, repo }, 'Configured to close stale issues') 57 | const closableItems = (await this.getClosable(type)).data.items 58 | 59 | await Promise.all( 60 | closableItems 61 | .filter(issue => !issue.locked && issue.state !== 'closed') 62 | .map(issue => this.close(type, issue)) 63 | ) 64 | } else { 65 | this.logger.trace({ owner, repo }, 'Configured to leave stale issues open') 66 | } 67 | } 68 | 69 | getStale (type) { 70 | const onlyLabels = this.getConfigValue(type, 'onlyLabels') 71 | const staleLabel = this.getConfigValue(type, 'staleLabel') 72 | const exemptLabels = this.getConfigValue(type, 'exemptLabels') 73 | const exemptProjects = this.getConfigValue(type, 'exemptProjects') 74 | const exemptMilestones = this.getConfigValue(type, 'exemptMilestones') 75 | const exemptAssignees = this.getConfigValue(type, 'exemptAssignees') 76 | const labels = [staleLabel].concat(exemptLabels) 77 | const queryParts = labels.map(label => `-label:"${label}"`) 78 | queryParts.push(...onlyLabels.map(label => `label:"${label}"`)) 79 | queryParts.push(Stale.getQueryTypeRestriction(type)) 80 | 81 | queryParts.push(exemptProjects ? 'no:project' : '') 82 | queryParts.push(exemptMilestones ? 'no:milestone' : '') 83 | queryParts.push(exemptAssignees ? 'no:assignee' : '') 84 | 85 | const query = queryParts.join(' ') 86 | const days = this.getConfigValue(type, 'days') || this.getConfigValue(type, 'daysUntilStale') 87 | return this.search(type, days, query) 88 | } 89 | 90 | getClosable (type) { 91 | const staleLabel = this.getConfigValue(type, 'staleLabel') 92 | const queryTypeRestriction = Stale.getQueryTypeRestriction(type) 93 | const query = `label:"${staleLabel}" ${queryTypeRestriction}` 94 | const days = this.getConfigValue(type, 'days') || this.getConfigValue(type, 'daysUntilClose') 95 | return this.search(type, days, query) 96 | } 97 | 98 | static getQueryTypeRestriction (type) { 99 | if (type === 'pulls') { 100 | return 'is:pr' 101 | } else if (type === 'issues') { 102 | return 'is:issue' 103 | } 104 | throw new Error(`Unknown type: ${type}. Valid types are 'pulls' and 'issues'`) 105 | } 106 | 107 | search (type, days, query) { 108 | const { owner, repo } = this.config 109 | const timestamp = this.since(days).toISOString().replace(/\.\d{3}\w$/, '') 110 | 111 | query = `repo:${owner}/${repo} is:open updated:<${timestamp} ${query}` 112 | 113 | const params = { q: query, sort: 'updated', order: 'desc', per_page: maxActionsPerRun } 114 | 115 | this.logger.info(params, 'searching %s/%s for stale issues', owner, repo) 116 | return this.github.search.issues(params) 117 | } 118 | 119 | async markIssue (type, issue) { 120 | if (this.remainingActions === 0) { 121 | return 122 | } 123 | this.remainingActions-- 124 | 125 | const { owner, repo } = this.config 126 | const perform = this.getConfigValue(type, 'perform') 127 | const staleLabel = this.getConfigValue(type, 'staleLabel') 128 | const markComment = this.getConfigValue(type, 'markComment') 129 | const number = issue.number 130 | 131 | if (perform) { 132 | this.logger.info('%s/%s#%d is being marked', owner, repo, number) 133 | if (markComment) { 134 | await this.github.issues.createComment({ owner, repo, number, body: markComment }) 135 | } 136 | return this.github.issues.addLabels({ owner, repo, number, labels: [staleLabel] }) 137 | } else { 138 | this.logger.info('%s/%s#%d would have been marked (dry-run)', owner, repo, number) 139 | } 140 | } 141 | 142 | async close (type, issue) { 143 | if (this.remainingActions === 0) { 144 | return 145 | } 146 | this.remainingActions-- 147 | 148 | const { owner, repo } = this.config 149 | const perform = this.getConfigValue(type, 'perform') 150 | const closeComment = this.getConfigValue(type, 'closeComment') 151 | const number = issue.number 152 | 153 | if (perform) { 154 | this.logger.info('%s/%s#%d is being closed', owner, repo, number) 155 | if (closeComment) { 156 | await this.github.issues.createComment({ owner, repo, number, body: closeComment }) 157 | } 158 | return this.github.issues.edit({ owner, repo, number, state: 'closed' }) 159 | } else { 160 | this.logger.info('%s/%s#%d would have been closed (dry-run)', owner, repo, number) 161 | } 162 | } 163 | 164 | async unmarkIssue (type, issue) { 165 | const { owner, repo } = this.config 166 | const perform = this.getConfigValue(type, 'perform') 167 | const staleLabel = this.getConfigValue(type, 'staleLabel') 168 | const unmarkComment = this.getConfigValue(type, 'unmarkComment') 169 | const number = issue.number 170 | 171 | if (perform) { 172 | this.logger.info('%s/%s#%d is being unmarked', owner, repo, number) 173 | 174 | if (unmarkComment) { 175 | await this.github.issues.createComment({ owner, repo, number, body: unmarkComment }) 176 | } 177 | 178 | return this.github.issues.removeLabel({ owner, repo, number, name: staleLabel }).catch((err) => { 179 | // ignore if it's a 404 because then the label was already removed 180 | if (err.code !== 404) { 181 | throw err 182 | } 183 | }) 184 | } else { 185 | this.logger.info('%s/%s#%d would have been unmarked (dry-run)', owner, repo, number) 186 | } 187 | } 188 | 189 | // Returns true if at least one exempt label is present. 190 | hasExemptLabel (type, issue) { 191 | const exemptLabels = this.getConfigValue(type, 'exemptLabels') 192 | return issue.labels.some(label => exemptLabels.includes(label.name)) 193 | } 194 | 195 | hasStaleLabel (type, issue) { 196 | const staleLabel = this.getConfigValue(type, 'staleLabel') 197 | return issue.labels.map(label => label.name).includes(staleLabel) 198 | } 199 | 200 | // returns a type-specific config value if it exists, otherwise returns the top-level value. 201 | getConfigValue (type, key) { 202 | if (this.config[type] && typeof this.config[type][key] !== 'undefined') { 203 | return this.config[type][key] 204 | } 205 | return this.config[key] 206 | } 207 | 208 | async ensureStaleLabelExists (type) { 209 | const { owner, repo } = this.config 210 | const staleLabel = this.getConfigValue(type, 'staleLabel') 211 | 212 | return this.github.issues.getLabel({ owner, repo, name: staleLabel }).catch(() => { 213 | return this.github.issues.createLabel({ owner, repo, name: staleLabel, color: 'ffffff' }) 214 | }) 215 | } 216 | 217 | since (days) { 218 | const ttl = days * 24 * 60 * 60 * 1000 219 | let date = new Date(new Date() - ttl) 220 | 221 | // GitHub won't allow it 222 | if (date < new Date(0)) { 223 | date = new Date(0) 224 | } 225 | return date 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /newrelic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * New Relic agent configuration. 4 | * 5 | * See lib/config/default.js in the agent distribution for a more complete 6 | * description of configuration variables and their potential values. 7 | */ 8 | exports.config = { 9 | /** 10 | * Array of application names. 11 | */ 12 | app_name: ['Probot Stale'], 13 | /** 14 | * Your New Relic license key. 15 | */ 16 | license_key: process.env.NEW_RELIC_KEY, 17 | logging: { 18 | /** 19 | * Level at which to log. 'trace' is most useful to New Relic when diagnosing 20 | * issues with the agent, 'info' and higher will impose the least overhead on 21 | * production applications. 22 | */ 23 | level: 'trace' 24 | }, 25 | /** 26 | * When true, all request headers except for those listed in attributes.exclude 27 | * will be captured for all traces, unless otherwise specified in a destination's 28 | * attributes include/exclude lists. 29 | */ 30 | allow_all_headers: true, 31 | attributes: { 32 | /** 33 | * Prefix of attributes to exclude from all destinations. Allows * as wildcard 34 | * at end. 35 | * 36 | * NOTE: If excluding headers, they must be in camelCase form to be filtered. 37 | * 38 | * @env NEW_RELIC_ATTRIBUTES_EXCLUDE 39 | */ 40 | exclude: [ 41 | 'request.headers.cookie', 42 | 'request.headers.authorization', 43 | 'request.headers.proxyAuthorization', 44 | 'request.headers.setCookie*', 45 | 'request.headers.x*', 46 | 'response.headers.cookie', 47 | 'response.headers.authorization', 48 | 'response.headers.proxyAuthorization', 49 | 'response.headers.setCookie*', 50 | 'response.headers.x*' 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "probot-stale", 3 | "version": "1.1.0", 4 | "description": "A GitHub App built with Probot that closes abandoned Issues and Pull Requests after a period of inactivity.", 5 | "author": "Brandon Keepers", 6 | "license": "ISC", 7 | "homepage": "https://probot.github.io/apps/stale/", 8 | "keywords": [ 9 | "probot", 10 | "github", 11 | "probot-app" 12 | ], 13 | "repository": "github:probot/stale", 14 | "scripts": { 15 | "start": "probot run ./index.js", 16 | "test": "jest && standard" 17 | }, 18 | "dependencies": { 19 | "@hapi/joi": "^15.0.0", 20 | "newrelic": "^5.2.1", 21 | "probot": "7.3.1", 22 | "probot-config": "^0.1.0", 23 | "probot-scheduler": "^1.0.2" 24 | }, 25 | "engines": { 26 | "node": "^8.9", 27 | "npm": "^5.6" 28 | }, 29 | "devDependencies": { 30 | "jest": "^22.2.2", 31 | "smee-client": "^1.0.1", 32 | "standard": "^12.0.1" 33 | }, 34 | "standard": { 35 | "env": [ 36 | "jest" 37 | ] 38 | }, 39 | "jest": { 40 | "testEnvironment": "node" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/schema.test.js: -------------------------------------------------------------------------------- 1 | const schema = require('../lib/schema') 2 | 3 | const validConfigs = [ 4 | [{ daysUntilClose: false }], 5 | [{ daysUntilClose: 1 }], 6 | [{ onlyLabels: ['foo'] }], 7 | [{ onlyLabels: 'foo' }, { onlyLabels: ['foo'] }], 8 | [{ onlyLabels: null }], 9 | [{ onlyLabels: [] }], 10 | [{ exemptLabels: ['foo'] }], 11 | [{ exemptLabels: 'foo' }, { exemptLabels: ['foo'] }], 12 | [{ exemptLabels: null }], 13 | [{ exemptLabels: [] }], 14 | [{ exemptProjects: true }], 15 | [{ exemptProjects: false }], 16 | [{ exemptMilestones: true }], 17 | [{ exemptMilestones: false }], 18 | [{ exemptAssignees: true }], 19 | [{ exemptAssignees: false }], 20 | [{ staleLabel: 'stale' }], 21 | [{ markComment: 'stale yo' }], 22 | [{ markComment: false }], 23 | [{ unmarkComment: 'not stale' }], 24 | [{ unmarkComment: false }], 25 | [{ closeComment: 'closing yo' }], 26 | [{ closeComment: false }], 27 | [{ limitPerRun: 1 }], 28 | [{ limitPerRun: 30 }], 29 | [{ only: null }], 30 | [{ only: 'issues' }], 31 | [{ only: 'pulls' }], 32 | [{ pulls: { daysUntilStale: 2 } }], 33 | [{ issues: { staleLabel: 'stale-issue' } }], 34 | [{ _extends: '.github' }], 35 | [{ _extends: 'foobar' }] 36 | ] 37 | 38 | const invalidConfigs = [ 39 | [{ daysUntilClose: true }, 'must be a number or false'], 40 | [{ exemptProjects: 'nope' }, 'must be a boolean'], 41 | [{ exemptMilestones: 'nope' }, 'must be a boolean'], 42 | [{ exemptAssignees: 'nope' }, 'must be a boolean'], 43 | [{ staleLabel: '' }, 'not allowed to be empty'], 44 | [{ staleLabel: false }, 'must be a string'], 45 | [{ staleLabel: ['a', 'b'] }, 'must be a string'], 46 | [{ markComment: true }, 'must be a string or false'], 47 | [{ unmarkComment: true }, 'must be a string or false'], 48 | [{ closeComment: true }, 'must be a string or false'], 49 | [{ limitPerRun: 31 }, 'must be an integer between 1 and 30'], 50 | [{ limitPerRun: 0 }, 'must be an integer between 1 and 30'], 51 | [{ limitPerRun: 0.5 }, 'must be an integer between 1 and 30'], 52 | [{ only: 'donuts' }, 'must be one of [issues, pulls, null]'], 53 | [{ pulls: { daysUntilStale: 'no' } }, 'must be a number'], 54 | [{ pulls: { lol: 'nope' } }, '"lol" is not allowed'], 55 | [{ issues: { staleLabel: '' } }, 'not allowed to be empty'], 56 | [{ _extends: true }, 'must be a string'], 57 | [{ _extends: false }, 'must be a string'] 58 | ] 59 | 60 | describe('schema', () => { 61 | test('defaults', async () => { 62 | expect(schema.validate({}).value).toEqual({ 63 | daysUntilStale: 60, 64 | daysUntilClose: 7, 65 | onlyLabels: [], 66 | exemptLabels: ['pinned', 'security'], 67 | exemptProjects: false, 68 | exemptMilestones: false, 69 | exemptAssignees: false, 70 | staleLabel: 'wontfix', 71 | perform: true, 72 | markComment: 'Is this still relevant? If so, what is blocking it? ' + 73 | 'Is there anything you can do to help move it forward?' + 74 | '\n\nThis issue has been automatically marked as stale ' + 75 | 'because it has not had recent activity. ' + 76 | 'It will be closed if no further activity occurs.', 77 | unmarkComment: false, 78 | closeComment: false, 79 | limitPerRun: 30 80 | }) 81 | }) 82 | 83 | test('does not set defaults for pulls and issues', () => { 84 | expect(schema.validate({ pulls: { daysUntilStale: 90 } }).value.pulls).toEqual({ 85 | daysUntilStale: 90 86 | }) 87 | 88 | expect(schema.validate({ issues: { daysUntilStale: 90 } }).value.issues).toEqual({ 89 | daysUntilStale: 90 90 | }) 91 | }) 92 | 93 | validConfigs.forEach(([example, expected = example]) => { 94 | test(`${JSON.stringify(example)} is valid`, () => { 95 | const result = schema.validate(example) 96 | expect(result.error).toBe(null) 97 | expect(result.value).toMatchObject(expected) 98 | }) 99 | }) 100 | 101 | invalidConfigs.forEach(([example, message]) => { 102 | test(`${JSON.stringify(example)} is invalid`, () => { 103 | const { error } = schema.validate(example) 104 | expect(error && error.toString()).toMatch(message) 105 | }) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /test/stale.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | process.env.LOG_LEVEL = 'fatal' 3 | 4 | const { Application } = require('probot') 5 | const Stale = require('../lib/stale') 6 | const notFoundError = { 7 | code: 404, 8 | status: 'Not Found', 9 | headers: {} 10 | } 11 | 12 | describe('stale', () => { 13 | let app 14 | let github 15 | 16 | beforeEach(() => { 17 | app = new Application() 18 | 19 | const issueAction = jest.fn().mockImplementation(() => Promise.resolve(notFoundError)) 20 | 21 | // Mock out the GitHub API 22 | github = { 23 | integrations: { 24 | getInstallations: jest.fn() 25 | }, 26 | paginate: jest.fn(), 27 | issues: { 28 | removeLabel: issueAction, 29 | getLabel: jest.fn().mockImplementation(() => Promise.reject(notFoundError)), 30 | createLabel: issueAction, 31 | addLabels: issueAction, 32 | createComment: issueAction, 33 | edit: issueAction 34 | }, 35 | search: { 36 | issues: issueAction 37 | } 38 | } 39 | 40 | // Mock out GitHub client 41 | app.auth = () => Promise.resolve(github) 42 | }) 43 | 44 | test( 45 | 'removes the stale label and ignores if it has already been removed', 46 | async () => { 47 | let stale = new Stale(github, { perform: true, owner: 'probot', repo: 'stale', logger: app.log }) 48 | 49 | for (const type of ['pulls', 'issues']) { 50 | try { 51 | await stale.unmarkIssue(type, { number: 123 }) 52 | } catch (_) { 53 | throw new Error('Should not have thrown an error') 54 | } 55 | } 56 | } 57 | ) 58 | 59 | test('should limit the number of actions it takes each run', async () => { 60 | const staleLabel = 'stale' 61 | const limitPerRun = 30 62 | 63 | const issueCount = 40 64 | const staleCount = 3 65 | 66 | const issues = [] 67 | for (let i = 1; i <= issueCount; i++) { 68 | const labels = (i <= staleCount) ? [{ name: staleLabel }] : [] 69 | issues.push({ number: i, labels: labels }) 70 | } 71 | 72 | const prs = [] 73 | for (let i = 101; i <= 100 + issueCount; i++) { 74 | const labels = (i <= 100 + staleCount) ? [{ name: staleLabel }] : [] 75 | prs.push({ number: i, labels: labels }) 76 | } 77 | 78 | github.search.issues = ({ q, sort, order, per_page }) => { 79 | let items = [] 80 | if (q.includes('is:pr')) { 81 | items = items.concat(prs.slice(0, per_page)) 82 | } else if (q.includes('is:issue')) { 83 | items = items.concat(issues.slice(0, per_page)) 84 | } else { 85 | throw new Error('query should specify PullRequests or Issues') 86 | } 87 | 88 | if (q.includes(`-label:"${staleLabel}"`)) { 89 | items = items.filter(item => !item.labels.map(label => label.name).includes(staleLabel)) 90 | } else if (q.includes(`label:"${staleLabel}"`)) { 91 | items = items.filter(item => item.labels.map(label => label.name).includes(staleLabel)) 92 | } 93 | 94 | expect(items.length).toBeLessThanOrEqual(per_page) 95 | 96 | return Promise.resolve({ 97 | data: { 98 | items: items 99 | } 100 | }) 101 | } 102 | 103 | for (const type of ['pulls', 'issues']) { 104 | let comments = 0 105 | let closed = 0 106 | let labeledStale = 0 107 | github.issues.createComment = jest.fn().mockImplementation(() => { 108 | comments++ 109 | return Promise.resolve(notFoundError) 110 | }) 111 | github.issues.edit = ({ owner, repo, number, state }) => { 112 | if (state === 'closed') { 113 | closed++ 114 | } 115 | } 116 | github.issues.addLabels = ({ owner, repo, number, labels }) => { 117 | if (labels.includes(staleLabel)) { 118 | labeledStale++ 119 | } 120 | } 121 | 122 | // Mock out GitHub client 123 | app.auth = () => Promise.resolve(github) 124 | 125 | const stale = new Stale(github, { perform: true, owner: 'probot', repo: 'stale', logger: app.log }) 126 | stale.config.limitPerRun = limitPerRun 127 | stale.config.staleLabel = staleLabel 128 | stale.config.closeComment = 'closed' 129 | 130 | await stale.markAndSweep(type) 131 | 132 | expect(comments).toEqual(limitPerRun) 133 | expect(closed).toEqual(staleCount) 134 | expect(labeledStale).toEqual(limitPerRun - staleCount) 135 | } 136 | }) 137 | 138 | test( 139 | 'should not close issues if daysUntilClose is configured as false', 140 | async () => { 141 | let stale = new Stale(github, { perform: true, owner: 'probot', repo: 'stale', logger: app.log }) 142 | stale.config.daysUntilClose = false 143 | stale.getStale = jest.fn().mockImplementation(() => Promise.resolve({ data: { items: [] } })) 144 | stale.getClosable = jest.fn() 145 | 146 | await stale.markAndSweep('issues') 147 | expect(stale.getClosable).not.toHaveBeenCalled() 148 | 149 | await stale.markAndSweep('pulls') 150 | expect(stale.getClosable).not.toHaveBeenCalled() 151 | } 152 | ) 153 | 154 | test( 155 | 'should not close issues if the keyword pulls or keyword issues is used, and daysUntilClose is configured as false', 156 | async () => { 157 | let stale = new Stale(github, { perform: true, owner: 'probot', repo: 'stale', logger: app.log }) 158 | stale.config.pulls = { daysUntilClose: false } 159 | stale.config.issues = { daysUntilClose: false } 160 | stale.getStale = jest.fn().mockImplementation(() => Promise.resolve({ data: { items: [] } })) 161 | stale.getClosable = jest.fn() 162 | 163 | await stale.markAndSweep('issues') 164 | expect(stale.getClosable).not.toHaveBeenCalled() 165 | 166 | await stale.markAndSweep('pulls') 167 | expect(stale.getClosable).not.toHaveBeenCalled() 168 | } 169 | ) 170 | 171 | test( 172 | 'should not close issues if only keyword is configured with the pulls value', 173 | async () => { 174 | let stale = new Stale(github, { perform: true, owner: 'probot', repo: 'stale', logger: app.log }) 175 | stale.config.only = 'pulls' 176 | stale.config.daysUntilClose = 1 177 | stale.getStale = jest.fn().mockImplementation(() => Promise.resolve({ data: { items: [] } })) 178 | stale.getClosable = jest.fn() 179 | 180 | await stale.markAndSweep('issues') 181 | expect(stale.getClosable).not.toHaveBeenCalled() 182 | } 183 | ) 184 | 185 | test( 186 | 'should not close pull requests if only keyword is configured with the issues value', 187 | async () => { 188 | let stale = new Stale(github, { perform: true, owner: 'probot', repo: 'stale', logger: app.log }) 189 | stale.config.only = 'issues' 190 | stale.config.daysUntilClose = 1 191 | stale.getStale = jest.fn().mockImplementation(() => Promise.resolve({ data: { items: [] } })) 192 | stale.getClosable = jest.fn() 193 | 194 | await stale.markAndSweep('pulls') 195 | expect(stale.getClosable).not.toHaveBeenCalled() 196 | } 197 | ) 198 | 199 | describe('mark', () => { 200 | test( 201 | 'should not mark issue if it is already closed', 202 | async () => { 203 | let stale = new Stale(github, { perform: true, owner: 'probot', repo: 'stale', logger: app.log }) 204 | stale.getStale = jest.fn().mockImplementation(() => { 205 | return Promise.resolve({ 206 | data: { 207 | items: [ 208 | { number: 1, state: 'closed' } 209 | ] 210 | } 211 | }) 212 | }) 213 | stale.markIssue = jest.fn() 214 | 215 | await stale.mark('issues') 216 | expect(stale.markIssue).not.toHaveBeenCalled() 217 | } 218 | ) 219 | 220 | test( 221 | 'should not mark issue if it is locked', 222 | async () => { 223 | let stale = new Stale(github, { perform: true, owner: 'probot', repo: 'stale', logger: app.log }) 224 | stale.getStale = jest.fn().mockImplementation(() => { 225 | return Promise.resolve({ 226 | data: { 227 | items: [ 228 | { number: 1, state: 'open', locked: true } 229 | ] 230 | } 231 | }) 232 | }) 233 | stale.markIssue = jest.fn() 234 | 235 | await stale.mark('issues') 236 | expect(stale.markIssue).not.toHaveBeenCalled() 237 | } 238 | ) 239 | 240 | test( 241 | 'should mark issue if it is open', 242 | async () => { 243 | let stale = new Stale(github, { perform: true, owner: 'probot', repo: 'stale', logger: app.log }) 244 | stale.getStale = jest.fn().mockImplementation(() => { 245 | return Promise.resolve({ 246 | data: { 247 | items: [ 248 | { number: 1, state: 'open' } 249 | ] 250 | } 251 | }) 252 | }) 253 | stale.markIssue = jest.fn() 254 | 255 | await stale.mark('issues') 256 | expect(stale.markIssue).toHaveBeenCalled() 257 | } 258 | ) 259 | }) 260 | 261 | describe('sweep', () => { 262 | test( 263 | 'should not close issue if it is already closed', 264 | async () => { 265 | const staleLabel = 'stale' 266 | let stale = new Stale(github, { perform: true, owner: 'probot', repo: 'stale', logger: app.log }) 267 | stale.config.daysUntilClose = 1 268 | stale.getClosable = jest.fn().mockImplementation(() => { 269 | return Promise.resolve({ 270 | data: { 271 | items: [ 272 | { number: 1, labels: [{ name: staleLabel }], state: 'closed' } 273 | ] 274 | } 275 | }) 276 | }) 277 | stale.close = jest.fn() 278 | 279 | await stale.sweep('issues') 280 | expect(stale.close).not.toHaveBeenCalled() 281 | } 282 | ) 283 | 284 | test( 285 | 'should not close issue if it is locked', 286 | async () => { 287 | const staleLabel = 'stale' 288 | let stale = new Stale(github, { perform: true, owner: 'probot', repo: 'stale', logger: app.log }) 289 | stale.config.daysUntilClose = 1 290 | stale.getClosable = jest.fn().mockImplementation(() => { 291 | return Promise.resolve({ 292 | data: { 293 | items: [ 294 | { number: 1, labels: [{ name: staleLabel }], state: 'open', locked: true } 295 | ] 296 | } 297 | }) 298 | }) 299 | stale.close = jest.fn() 300 | 301 | await stale.sweep('issues') 302 | expect(stale.close).not.toHaveBeenCalled() 303 | } 304 | ) 305 | 306 | test( 307 | 'should close issue if it is open', 308 | async () => { 309 | const staleLabel = 'stale' 310 | let stale = new Stale(github, { perform: true, owner: 'probot', repo: 'stale', logger: app.log }) 311 | stale.config.daysUntilClose = 1 312 | stale.getClosable = jest.fn().mockImplementation(() => { 313 | return Promise.resolve({ 314 | data: { 315 | items: [ 316 | { number: 1, labels: [{ name: staleLabel }], state: 'open' } 317 | ] 318 | } 319 | }) 320 | }) 321 | stale.close = jest.fn() 322 | 323 | await stale.sweep('issues') 324 | expect(stale.close).toHaveBeenCalled() 325 | } 326 | ) 327 | }) 328 | }) 329 | --------------------------------------------------------------------------------