• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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