|
| 1 | +# A workflow copied from the pytorch/pytorch repo stale PRs that implements similar logic to actions/stale. |
| 2 | +# |
| 3 | +# Compared to actions/stale, it is implemented to make API requests proportional |
| 4 | +# to the number of stale PRs, not the total number of issues in the repo. This |
| 5 | +# is because PyTorch has a lot of issues/PRs, so the actions/stale runs into |
| 6 | +# rate limits way too quickly. |
| 7 | +# |
| 8 | +# The behavior is: |
| 9 | +# - If a PR is not labeled stale, after 60 days inactivity label the PR as stale and comment about it. |
| 10 | +# - If a PR is labeled stale, after 30 days inactivity close the PR. |
| 11 | +# - `high priority` and `no-stale` PRs are exempt. |
| 12 | + |
| 13 | +name: Close stale pull requests |
| 14 | + |
| 15 | +on: |
| 16 | + schedule: |
| 17 | + # Run at midnight UTC. |
| 18 | + - cron: '0 0 * * *' |
| 19 | + workflow_dispatch: |
| 20 | + |
| 21 | +jobs: |
| 22 | + stale: |
| 23 | + if: ${{ github.repository == 'pytorch/tutorials' }} |
| 24 | + runs-on: ubuntu-latest |
| 25 | + permissions: |
| 26 | + contents: read |
| 27 | + pull-requests: write |
| 28 | + |
| 29 | + steps: |
| 30 | + - uses: actions/github-script@v6 |
| 31 | + with: |
| 32 | + script: | |
| 33 | + // Do some dumb retries on requests. |
| 34 | + const retries = 7; |
| 35 | + const baseBackoff = 100; |
| 36 | + const sleep = timeout => new Promise(resolve => setTimeout(resolve, timeout)); |
| 37 | + github.hook.wrap('request', async (request, options) => { |
| 38 | + for (let attempt = 1; attempt <= retries; attempt++) { |
| 39 | + try { |
| 40 | + return await request(options); |
| 41 | + } catch (err) { |
| 42 | + if (attempt < retries) { |
| 43 | + core.warning(`Request getting retried. Attempt: ${attempt}`); |
| 44 | + await sleep(baseBackoff * Math.pow(2, attempt)); |
| 45 | + continue; |
| 46 | + } |
| 47 | + throw err; |
| 48 | + } |
| 49 | + } |
| 50 | + }); |
| 51 | +
|
| 52 | + const MAX_API_REQUESTS = 100; |
| 53 | +
|
| 54 | + // If a PRs not labeled stale, label them stale after no update for 60 days. |
| 55 | + const STALE_LABEL_THRESHOLD_MS = 1000 * 60 * 60 * 24 * 60; |
| 56 | + // For PRs already labeled stale, close after not update for 30 days. |
| 57 | + const STALE_CLOSE_THRESHOLD_MS = 1000 * 60 * 60 * 24 * 30; |
| 58 | +
|
| 59 | + const STALE_MESSAGE = |
| 60 | + "Looks like this PR hasn't been updated in a while so we're going to go ahead and mark this as `Stale`. <br>" + |
| 61 | + "Feel free to remove the `Stale` label if you feel this was a mistake. <br>" + |
| 62 | + "If you are unable to remove the `Stale` label please contact a maintainer in order to do so. <br>" + |
| 63 | + "If you want the bot to never mark this PR stale again, add the `no-stale` label.<br>" + |
| 64 | + "`Stale` pull requests will automatically be closed after 30 days of inactivity.<br>"; |
| 65 | +
|
| 66 | + let numAPIRequests = 0; |
| 67 | + let numProcessed = 0; |
| 68 | +
|
| 69 | + async function processPull(pull) { |
| 70 | + core.info(`[${pull.number}] URL: ${pull.html_url}`); |
| 71 | + numProcessed += 1; |
| 72 | + const labels = pull.labels.map((label) => label.name); |
| 73 | +
|
| 74 | + // Skip if certain labels are present. |
| 75 | + if (labels.includes("no-stale") || labels.includes("high priority")) { |
| 76 | + core.info(`[${pull.number}] Skipping because PR has an exempting label.`); |
| 77 | + return false; |
| 78 | + } |
| 79 | +
|
| 80 | + // Check if the PR is stale, according to our configured thresholds. |
| 81 | + let staleThresholdMillis; |
| 82 | + if (labels.includes("Stale")) { |
| 83 | + core.info(`[${pull.number}] PR is labeled stale, checking whether we should close it.`); |
| 84 | + staleThresholdMillis = STALE_CLOSE_THRESHOLD_MS; |
| 85 | + } else { |
| 86 | + core.info(`[${pull.number}] Checking whether to label PR as stale.`); |
| 87 | + staleThresholdMillis = STALE_LABEL_THRESHOLD_MS; |
| 88 | + } |
| 89 | +
|
| 90 | + const millisSinceLastUpdated = |
| 91 | + new Date().getTime() - new Date(pull.updated_at).getTime(); |
| 92 | +
|
| 93 | + if (millisSinceLastUpdated < staleThresholdMillis) { |
| 94 | + core.info(`[${pull.number}] Skipping because PR was updated recently`); |
| 95 | + return false; |
| 96 | + } |
| 97 | +
|
| 98 | + // At this point, we know we should do something. |
| 99 | + // For PRs already labeled stale, close them. |
| 100 | + if (labels.includes("Stale")) { |
| 101 | + core.info(`[${pull.number}] Closing PR.`); |
| 102 | + numAPIRequests += 1; |
| 103 | + await github.rest.issues.update({ |
| 104 | + owner: "pytorch", |
| 105 | + repo: "tutorials", |
| 106 | + issue_number: pull.number, |
| 107 | + state: "closed", |
| 108 | + }); |
| 109 | + } else { |
| 110 | + // For PRs not labeled stale, label them stale. |
| 111 | + core.info(`[${pull.number}] Labeling PR as stale.`); |
| 112 | +
|
| 113 | + numAPIRequests += 1; |
| 114 | + await github.rest.issues.createComment({ |
| 115 | + owner: "pytorch", |
| 116 | + repo: "tutorials", |
| 117 | + issue_number: pull.number, |
| 118 | + body: STALE_MESSAGE, |
| 119 | + }); |
| 120 | +
|
| 121 | + numAPIRequests += 1; |
| 122 | + await github.rest.issues.addLabels({ |
| 123 | + owner: "pytorch", |
| 124 | + repo: "tutorials", |
| 125 | + issue_number: pull.number, |
| 126 | + labels: ["Stale"], |
| 127 | + }); |
| 128 | + } |
| 129 | + } |
| 130 | +
|
| 131 | + for await (const response of github.paginate.iterator( |
| 132 | + github.rest.pulls.list, |
| 133 | + { |
| 134 | + owner: "pytorch", |
| 135 | + repo: "tutorials", |
| 136 | + state: "open", |
| 137 | + sort: "created", |
| 138 | + direction: "asc", |
| 139 | + per_page: 100, |
| 140 | + } |
| 141 | + )) { |
| 142 | + numAPIRequests += 1; |
| 143 | + const pulls = response.data; |
| 144 | + // Awaiting in a loop is intentional here. We want to serialize execution so |
| 145 | + // that log groups are printed correctl |
| 146 | + for (const pull of pulls) { |
| 147 | + if (numAPIRequests > MAX_API_REQUESTS) { |
| 148 | + core.warning("Max API requests exceeded, exiting."); |
| 149 | + process.exit(0); |
| 150 | + } |
| 151 | + await core.group(`Processing PR #${pull.number}`, async () => { |
| 152 | + await processPull(pull); |
| 153 | + }); |
| 154 | + } |
| 155 | + } |
| 156 | + core.info(`Processed ${numProcessed} PRs total.`); |
| 157 | +
|
0 commit comments