Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
41f30e8
commit 01b99b3
Showing
3 changed files
with
365 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
const Joi = require('@hapi/joi') | ||
|
||
const fields = { | ||
daysUntilStale: Joi.number() | ||
.description('Number of days of inactivity before an Issue or Pull Request becomes stale'), | ||
|
||
daysUntilClose: Joi.alternatives().try(Joi.number(), Joi.boolean().only(false)) | ||
.error(() => '"daysUntilClose" must be a number or false') | ||
.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.'), | ||
|
||
onlyLabels: Joi.alternatives().try(Joi.any().valid(null), Joi.array().single()) | ||
.description('Only issues or pull requests with all of these labels are checked for staleness. Set to `[]` to disable'), | ||
|
||
exemptLabels: Joi.alternatives().try(Joi.any().valid(null), Joi.array().single()) | ||
.description('Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable'), | ||
|
||
exemptProjects: Joi.boolean() | ||
.description('Set to true to ignore issues in a project (defaults to false)'), | ||
|
||
exemptMilestones: Joi.boolean() | ||
.description('Set to true to ignore issues in a milestone (defaults to false)'), | ||
|
||
exemptAssignees: Joi.boolean() | ||
.description('Set to true to ignore issues with an assignee (defaults to false)'), | ||
|
||
staleLabel: Joi.string() | ||
.description('Label to use when marking as stale'), | ||
|
||
markComment: Joi.alternatives().try(Joi.string(), Joi.any().only(false)) | ||
.error(() => '"markComment" must be a string or false') | ||
.description('Comment to post when marking as stale. Set to `false` to disable'), | ||
|
||
unmarkComment: Joi.alternatives().try(Joi.string(), Joi.boolean().only(false)) | ||
.error(() => '"unmarkComment" must be a string or false') | ||
.description('Comment to post when removing the stale label. Set to `false` to disable'), | ||
|
||
closeComment: Joi.alternatives().try(Joi.string(), Joi.boolean().only(false)) | ||
.error(() => '"closeComment" must be a string or false') | ||
.description('Comment to post when closing a stale Issue or Pull Request. Set to `false` to disable'), | ||
|
||
limitPerRun: Joi.number().integer().min(1).max(30) | ||
.error(() => '"limitPerRun" must be an integer between 1 and 30') | ||
.description('Limit the number of actions per hour, from 1-30. Default is 30') | ||
} | ||
|
||
const schema = Joi.object().keys({ | ||
daysUntilStale: fields.daysUntilStale.default(60), | ||
daysUntilClose: fields.daysUntilClose.default(7), | ||
onlyLabels: fields.onlyLabels.default([]), | ||
exemptLabels: fields.exemptLabels.default(['pinned', 'security']), | ||
exemptProjects: fields.exemptProjects.default(false), | ||
exemptMilestones: fields.exemptMilestones.default(false), | ||
exemptAssignees: fields.exemptMilestones.default(false), | ||
staleLabel: fields.staleLabel.default('wontfix'), | ||
markComment: fields.markComment.default( | ||
'Is this still relevant? If so, what is blocking it? ' + | ||
'Is there anything you can do to help move it forward?' + | ||
'\n\nThis issue has been automatically marked as stale ' + | ||
'because it has not had recent activity. ' + | ||
'It will be closed if no further activity occurs.' | ||
), | ||
unmarkComment: fields.unmarkComment.default(false), | ||
closeComment: fields.closeComment.default(false), | ||
limitPerRun: fields.limitPerRun.default(30), | ||
perform: Joi.boolean().default(!process.env.DRY_RUN), | ||
only: Joi.any().valid('issues', 'pulls', null).description('Limit to only `issues` or `pulls`'), | ||
pulls: Joi.object().keys(fields), | ||
issues: Joi.object().keys(fields), | ||
_extends: Joi.string().description('Repository to extend settings from') | ||
}) | ||
|
||
module.exports = schema |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
require('newrelic') | ||
|
||
const getConfig = require('probot-config') | ||
const createScheduler = require('probot-scheduler') | ||
const Stale = require('./stale') | ||
|
||
module.exports = async app => { | ||
// Visit all repositories to mark and sweep stale issues | ||
const scheduler = createScheduler(app) | ||
|
||
// Unmark stale issues if a user comments | ||
const events = [ | ||
'issue_comment', | ||
'issues', | ||
'pull_request', | ||
'pull_request_review', | ||
'pull_request_review_comment' | ||
] | ||
|
||
app.on(events, unmark) | ||
app.on('schedule.repository', markAndSweep) | ||
|
||
async function unmark (context) { | ||
if (!context.isBot) { | ||
const stale = await forRepository(context) | ||
let issue = context.payload.issue || context.payload.pull_request | ||
const type = context.payload.issue ? 'issues' : 'pulls' | ||
|
||
// Some payloads don't include labels | ||
if (!issue.labels) { | ||
try { | ||
issue = (await context.github.issues.get(context.issue())).data | ||
} catch (error) { | ||
context.log('Issue not found') | ||
} | ||
} | ||
|
||
const staleLabelAdded = context.payload.action === 'labeled' && | ||
context.payload.label.name === stale.config.staleLabel | ||
|
||
if (stale.hasStaleLabel(type, issue) && issue.state !== 'closed' && !staleLabelAdded) { | ||
stale.unmarkIssue(type, issue) | ||
} | ||
} | ||
} | ||
|
||
async function markAndSweep (context) { | ||
const stale = await forRepository(context) | ||
await stale.markAndSweep('pulls') | ||
await stale.markAndSweep('issues') | ||
} | ||
|
||
async function forRepository (context) { | ||
let config = await getConfig(context, 'stale.yml') | ||
|
||
if (!config) { | ||
scheduler.stop(context.payload.repository) | ||
// Don't actually perform for repository without a config | ||
config = { perform: false } | ||
} | ||
|
||
config = Object.assign(config, context.repo({ logger: app.log })) | ||
|
||
return new Stale(context.github, config) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,227 @@ | ||
const schema = require('./schema') | ||
const maxActionsPerRun = 30 | ||
|
||
module.exports = class Stale { | ||
constructor (github, { owner, repo, logger = console, ...config }) { | ||
this.github = github | ||
this.logger = logger | ||
this.remainingActions = 0 | ||
|
||
const { error, value } = schema.validate(config) | ||
|
||
this.config = value | ||
if (error) { | ||
// Report errors to sentry | ||
logger.warn({ err: new Error(error), owner, repo }, 'Invalid config') | ||
} | ||
|
||
Object.assign(this.config, { owner, repo }) | ||
} | ||
|
||
async markAndSweep (type) { | ||
const { only } = this.config | ||
if (only && only !== type) { | ||
return | ||
} | ||
if (!this.getConfigValue(type, 'perform')) { | ||
return | ||
} | ||
|
||
this.logger.info(this.config, `starting mark and sweep of ${type}`) | ||
|
||
const limitPerRun = this.getConfigValue(type, 'limitPerRun') || maxActionsPerRun | ||
this.remainingActions = Math.min(limitPerRun, maxActionsPerRun) | ||
|
||
await this.mark(type) | ||
await this.sweep(type) | ||
} | ||
|
||
async mark (type) { | ||
await this.ensureStaleLabelExists(type) | ||
|
||
const staleItems = (await this.getStale(type)).data.items | ||
|
||
await Promise.all( | ||
staleItems | ||
.filter(issue => !issue.locked && issue.state !== 'closed') | ||
.map(issue => this.markIssue(type, issue)) | ||
) | ||
} | ||
|
||
async sweep (type) { | ||
const { owner, repo } = this.config | ||
const daysUntilClose = this.getConfigValue(type, 'daysUntilClose') | ||
|
||
if (daysUntilClose) { | ||
this.logger.trace({ owner, repo }, 'Configured to close stale issues') | ||
const closableItems = (await this.getClosable(type)).data.items | ||
|
||
await Promise.all( | ||
closableItems | ||
.filter(issue => !issue.locked && issue.state !== 'closed') | ||
.map(issue => this.close(type, issue)) | ||
) | ||
} else { | ||
this.logger.trace({ owner, repo }, 'Configured to leave stale issues open') | ||
} | ||
} | ||
|
||
getStale (type) { | ||
const onlyLabels = this.getConfigValue(type, 'onlyLabels') | ||
const staleLabel = this.getConfigValue(type, 'staleLabel') | ||
const exemptLabels = this.getConfigValue(type, 'exemptLabels') | ||
const exemptProjects = this.getConfigValue(type, 'exemptProjects') | ||
const exemptMilestones = this.getConfigValue(type, 'exemptMilestones') | ||
const exemptAssignees = this.getConfigValue(type, 'exemptAssignees') | ||
const labels = [staleLabel].concat(exemptLabels) | ||
const queryParts = labels.map(label => `-label:"${label}"`) | ||
queryParts.push(...onlyLabels.map(label => `label:"${label}"`)) | ||
queryParts.push(Stale.getQueryTypeRestriction(type)) | ||
|
||
queryParts.push(exemptProjects ? 'no:project' : '') | ||
queryParts.push(exemptMilestones ? 'no:milestone' : '') | ||
queryParts.push(exemptAssignees ? 'no:assignee' : '') | ||
|
||
const query = queryParts.join(' ') | ||
const days = this.getConfigValue(type, 'days') || this.getConfigValue(type, 'daysUntilStale') | ||
return this.search(type, days, query) | ||
} | ||
|
||
getClosable (type) { | ||
const staleLabel = this.getConfigValue(type, 'staleLabel') | ||
const queryTypeRestriction = Stale.getQueryTypeRestriction(type) | ||
const query = `label:"${staleLabel}" ${queryTypeRestriction}` | ||
const days = this.getConfigValue(type, 'days') || this.getConfigValue(type, 'daysUntilClose') | ||
return this.search(type, days, query) | ||
} | ||
|
||
static getQueryTypeRestriction (type) { | ||
if (type === 'pulls') { | ||
return 'is:pr' | ||
} else if (type === 'issues') { | ||
return 'is:issue' | ||
} | ||
throw new Error(`Unknown type: ${type}. Valid types are 'pulls' and 'issues'`) | ||
} | ||
|
||
search (type, days, query) { | ||
const { owner, repo } = this.config | ||
const timestamp = this.since(days).toISOString().replace(/\.\d{3}\w$/, '') | ||
|
||
query = `repo:${owner}/${repo} is:open updated:<${timestamp} ${query}` | ||
|
||
const params = { q: query, sort: 'updated', order: 'desc', per_page: maxActionsPerRun } | ||
|
||
this.logger.info(params, 'searching %s/%s for stale issues', owner, repo) | ||
return this.github.search.issues(params) | ||
} | ||
|
||
async markIssue (type, issue) { | ||
if (this.remainingActions === 0) { | ||
return | ||
} | ||
this.remainingActions-- | ||
|
||
const { owner, repo } = this.config | ||
const perform = this.getConfigValue(type, 'perform') | ||
const staleLabel = this.getConfigValue(type, 'staleLabel') | ||
const markComment = this.getConfigValue(type, 'markComment') | ||
const number = issue.number | ||
|
||
if (perform) { | ||
this.logger.info('%s/%s#%d is being marked', owner, repo, number) | ||
if (markComment) { | ||
await this.github.issues.createComment({ owner, repo, number, body: markComment }) | ||
} | ||
return this.github.issues.addLabels({ owner, repo, number, labels: [staleLabel] }) | ||
} else { | ||
this.logger.info('%s/%s#%d would have been marked (dry-run)', owner, repo, number) | ||
} | ||
} | ||
|
||
async close (type, issue) { | ||
if (this.remainingActions === 0) { | ||
return | ||
} | ||
this.remainingActions-- | ||
|
||
const { owner, repo } = this.config | ||
const perform = this.getConfigValue(type, 'perform') | ||
const closeComment = this.getConfigValue(type, 'closeComment') | ||
const number = issue.number | ||
|
||
if (perform) { | ||
this.logger.info('%s/%s#%d is being closed', owner, repo, number) | ||
if (closeComment) { | ||
await this.github.issues.createComment({ owner, repo, number, body: closeComment }) | ||
} | ||
return this.github.issues.edit({ owner, repo, number, state: 'closed' }) | ||
} else { | ||
this.logger.info('%s/%s#%d would have been closed (dry-run)', owner, repo, number) | ||
} | ||
} | ||
|
||
async unmarkIssue (type, issue) { | ||
const { owner, repo } = this.config | ||
const perform = this.getConfigValue(type, 'perform') | ||
const staleLabel = this.getConfigValue(type, 'staleLabel') | ||
const unmarkComment = this.getConfigValue(type, 'unmarkComment') | ||
const number = issue.number | ||
|
||
if (perform) { | ||
this.logger.info('%s/%s#%d is being unmarked', owner, repo, number) | ||
|
||
if (unmarkComment) { | ||
await this.github.issues.createComment({ owner, repo, number, body: unmarkComment }) | ||
} | ||
|
||
return this.github.issues.removeLabel({ owner, repo, number, name: staleLabel }).catch((err) => { | ||
// ignore if it's a 404 because then the label was already removed | ||
if (err.code !== 404) { | ||
throw err | ||
} | ||
}) | ||
} else { | ||
this.logger.info('%s/%s#%d would have been unmarked (dry-run)', owner, repo, number) | ||
} | ||
} | ||
|
||
// Returns true if at least one exempt label is present. | ||
hasExemptLabel (type, issue) { | ||
const exemptLabels = this.getConfigValue(type, 'exemptLabels') | ||
return issue.labels.some(label => exemptLabels.includes(label.name)) | ||
} | ||
|
||
hasStaleLabel (type, issue) { | ||
const staleLabel = this.getConfigValue(type, 'staleLabel') | ||
return issue.labels.map(label => label.name).includes(staleLabel) | ||
} | ||
|
||
// returns a type-specific config value if it exists, otherwise returns the top-level value. | ||
getConfigValue (type, key) { | ||
if (this.config[type] && typeof this.config[type][key] !== 'undefined') { | ||
return this.config[type][key] | ||
} | ||
return this.config[key] | ||
} | ||
|
||
async ensureStaleLabelExists (type) { | ||
const { owner, repo } = this.config | ||
const staleLabel = this.getConfigValue(type, 'staleLabel') | ||
|
||
return this.github.issues.getLabel({ owner, repo, name: staleLabel }).catch(() => { | ||
return this.github.issues.createLabel({ owner, repo, name: staleLabel, color: 'ffffff' }) | ||
}) | ||
} | ||
|
||
since (days) { | ||
const ttl = days * 24 * 60 * 60 * 1000 | ||
let date = new Date(new Date() - ttl) | ||
|
||
// GitHub won't allow it | ||
if (date < new Date(0)) { | ||
date = new Date(0) | ||
} | ||
return date | ||
} | ||
} |
01b99b3
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deployment has failed due to an internal error. (code:
undefined
)Contact our support with support@vercel.com for more information.