Close stale pull requests #3490
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
# A workflow that implements similar logic to actions/stale. | |
# | |
# Compared to actions/stale, it is implemented to make API requests proportional | |
# to the number of stale PRs, not the total number of issues in the repo. This | |
# is because PyTorch has a lot of issues/PRs, so the actions/stale runs into | |
# rate limits way too quickly. | |
# | |
# The behavior is: | |
# - If a PR is not labeled stale, after 60 days inactivity label the PR as stale and comment about it. | |
# - If a PR is labeled stale, after 30 days inactivity close the PR. | |
# - `high priority` and `no-stale` PRs are exempt. | |
name: Close stale pull requests | |
on: | |
schedule: | |
# Run hourly. | |
- cron: 30 * * * * | |
workflow_dispatch: | |
jobs: | |
stale: | |
if: ${{ github.repository == 'pytorch/pytorch' }} | |
runs-on: linux.large | |
permissions: | |
contents: read | |
pull-requests: write | |
steps: | |
- uses: actions/github-script@v6 | |
with: | |
script: | | |
// Do some dumb retries on requests. | |
const retries = 7; | |
const baseBackoff = 100; | |
const sleep = timeout => new Promise(resolve => setTimeout(resolve, timeout)); | |
github.hook.wrap('request', async (request, options) => { | |
for (let attempt = 1; attempt <= retries; attempt++) { | |
try { | |
return await request(options); | |
} catch (err) { | |
if (attempt < retries) { | |
core.warning(`Request getting retried. Attempt: ${attempt}`); | |
await sleep(baseBackoff * Math.pow(2, attempt)); | |
continue; | |
} | |
throw err; | |
} | |
} | |
}); | |
const MAX_API_REQUESTS = 100; | |
// If a PRs not labeled stale, label them stale after no update for 60 days. | |
const STALE_LABEL_THRESHOLD_MS = 1000 * 60 * 60 * 24 * 60; | |
// For PRs already labeled stale, close after not update for 30 days. | |
const STALE_CLOSE_THRESHOLD_MS = 1000 * 60 * 60 * 24 * 30; | |
const STALE_MESSAGE = | |
"Looks like this PR hasn't been updated in a while so we're going to go ahead and mark this as `Stale`. <br>" + | |
"Feel free to remove the `Stale` label if you feel this was a mistake. <br>" + | |
"If you are unable to remove the `Stale` label please contact a maintainer in order to do so. <br>" + | |
"If you want the bot to never mark this PR stale again, add the `no-stale` label.<br>" + | |
"`Stale` pull requests will automatically be closed after 30 days of inactivity.<br>"; | |
let numAPIRequests = 0; | |
let numProcessed = 0; | |
async function processPull(pull) { | |
core.info(`[${pull.number}] URL: ${pull.html_url}`); | |
numProcessed += 1; | |
const labels = pull.labels.map((label) => label.name); | |
// Skip if certain labels are present. | |
if (labels.includes("no-stale") || labels.includes("high priority")) { | |
core.info(`[${pull.number}] Skipping because PR has an exempting label.`); | |
return false; | |
} | |
// Check if the PR is stale, according to our configured thresholds. | |
let staleThresholdMillis; | |
if (labels.includes("Stale")) { | |
core.info(`[${pull.number}] PR is labeled stale, checking whether we should close it.`); | |
staleThresholdMillis = STALE_CLOSE_THRESHOLD_MS; | |
} else { | |
core.info(`[${pull.number}] Checking whether to label PR as stale.`); | |
staleThresholdMillis = STALE_LABEL_THRESHOLD_MS; | |
} | |
const millisSinceLastUpdated = | |
new Date().getTime() - new Date(pull.updated_at).getTime(); | |
if (millisSinceLastUpdated < staleThresholdMillis) { | |
core.info(`[${pull.number}] Skipping because PR was updated recently`); | |
return false; | |
} | |
// At this point, we know we should do something. | |
// For PRs already labeled stale, close them. | |
if (labels.includes("Stale")) { | |
core.info(`[${pull.number}] Closing PR.`); | |
numAPIRequests += 1; | |
await github.rest.issues.update({ | |
owner: "pytorch", | |
repo: "pytorch", | |
issue_number: pull.number, | |
state: "closed", | |
}); | |
} else { | |
// For PRs not labeled stale, label them stale. | |
core.info(`[${pull.number}] Labeling PR as stale.`); | |
numAPIRequests += 1; | |
await github.rest.issues.createComment({ | |
owner: "pytorch", | |
repo: "pytorch", | |
issue_number: pull.number, | |
body: STALE_MESSAGE, | |
}); | |
numAPIRequests += 1; | |
await github.rest.issues.addLabels({ | |
owner: "pytorch", | |
repo: "pytorch", | |
issue_number: pull.number, | |
labels: ["Stale"], | |
}); | |
} | |
} | |
for await (const response of github.paginate.iterator( | |
github.rest.pulls.list, | |
{ | |
owner: "pytorch", | |
repo: "pytorch", | |
state: "open", | |
sort: "created", | |
direction: "asc", | |
per_page: 100, | |
} | |
)) { | |
numAPIRequests += 1; | |
const pulls = response.data; | |
// Awaiting in a loop is intentional here. We want to serialize execution so | |
// that log groups are printed correctl | |
for (const pull of pulls) { | |
if (numAPIRequests > MAX_API_REQUESTS) { | |
core.warning("Max API requests exceeded, exiting."); | |
process.exit(0); | |
} | |
await core.group(`Processing PR #${pull.number}`, async () => { | |
await processPull(pull); | |
}); | |
} | |
} | |
core.info(`Processed ${numProcessed} PRs total.`); |