1# A workflow 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 13name: Close stale pull requests 14 15on: 16 schedule: 17 # Run hourly. 18 - cron: 30 * * * * 19 workflow_dispatch: 20 21jobs: 22 stale: 23 if: ${{ github.repository == 'pytorch/pytorch' }} 24 runs-on: linux.large 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: "pytorch", 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: "pytorch", 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: "pytorch", 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: "pytorch", 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