ci: better stale issues (#1220)

Drew Smirnoff created

Change summary

.github/workflows/stale.yml | 125 ++++++++++++++++++++++++++++++++------
1 file changed, 104 insertions(+), 21 deletions(-)

Detailed changes

.github/workflows/stale.yml 🔗

@@ -13,25 +13,108 @@ jobs:
   stale:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/stale@v9
+      - uses: actions/github-script@v9
         with:
-          repo-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
-          days-before-issue-stale: 30
-          days-before-issue-close: 14
-          days-before-pr-stale: 45
-          days-before-pr-close: 21
-          stale-issue-label: "stale"
-          stale-pr-label: "stale"
-          exempt-issue-labels: "pinned,security,help-wanted,good-first-issue,in-progress"
-          exempt-pr-labels: "pinned,security,in-progress,blocked"
-          stale-issue-message: >
-            This issue has had no activity for 30 days. It will be closed in 14 days unless updated.
-            Add a comment or remove the `stale` label to keep it open.
-          stale-pr-message: >
-            This PR has had no activity for 45 days. It will be closed in 21 days unless updated.
-          close-issue-message: >
-            Closing due to inactivity. Reopen if still relevant.
-          close-pr-message: >
-            Closing due to inactivity. Reopen if still relevant.
-          remove-stale-when-updated: true
-          operations-per-run: 100
+          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
+          script: |
+            const IGNORED_AUTHORS = new Set(['floatpanebot', 'github-actions[bot]', 'renovate[bot]', 'dependabot[bot]']);
+            const STALE_LABEL = 'stale';
+            const EXEMPT_ISSUE = ['pinned', 'security', 'help-wanted', 'good-first-issue', 'in-progress'];
+            const EXEMPT_PR = ['pinned', 'security', 'in-progress', 'blocked'];
+            const ISSUE_STALE_DAYS = 30;
+            const ISSUE_CLOSE_DAYS = 14;
+            const PR_STALE_DAYS = 45;
+            const PR_CLOSE_DAYS = 21;
+            const OPS_LIMIT = 100;
+
+            const STALE_MSG = (days, close) =>
+              `This has had no activity for ${days} days. It will be closed in ${close} days unless updated. ` +
+              `Comment or remove the \`stale\` label to keep it open.`;
+            const CLOSE_MSG = 'Closing due to inactivity. Reopen if still relevant.';
+
+            const now = Date.now();
+            let ops = 0;
+
+            async function lastHumanActivity(issue) {
+              let latest = new Date(issue.created_at).getTime();
+              const comments = await github.paginate(github.rest.issues.listComments, {
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                issue_number: issue.number,
+                per_page: 100,
+              });
+              for (const c of comments) {
+                if (c.user && IGNORED_AUTHORS.has(c.user.login)) continue;
+                const t = new Date(c.created_at).getTime();
+                if (t > latest) latest = t;
+              }
+              if (issue.pull_request) {
+                const commits = await github.paginate(github.rest.pulls.listCommits, {
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  pull_number: issue.number,
+                  per_page: 100,
+                });
+                for (const c of commits) {
+                  const t = new Date(c.commit.author.date).getTime();
+                  if (t > latest) latest = t;
+                }
+              }
+              return latest;
+            }
+
+            const items = await github.paginate(github.rest.issues.listForRepo, {
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              state: 'open',
+              per_page: 100,
+            });
+
+            for (const item of items) {
+              if (ops >= OPS_LIMIT) { core.info('ops limit hit, stopping'); break; }
+              const isPR = !!item.pull_request;
+              const exempt = isPR ? EXEMPT_PR : EXEMPT_ISSUE;
+              const labels = item.labels.map(l => l.name || l);
+              if (exempt.some(l => labels.includes(l))) continue;
+
+              const staleDays = isPR ? PR_STALE_DAYS : ISSUE_STALE_DAYS;
+              const closeDays = isPR ? PR_CLOSE_DAYS : ISSUE_CLOSE_DAYS;
+              const isStale = labels.includes(STALE_LABEL);
+
+              const lastTs = await lastHumanActivity(item);
+              const ageDays = (now - lastTs) / 86400000;
+
+              if (!isStale && ageDays >= staleDays) {
+                await github.rest.issues.addLabels({
+                  owner: context.repo.owner, repo: context.repo.repo,
+                  issue_number: item.number, labels: [STALE_LABEL],
+                });
+                await github.rest.issues.createComment({
+                  owner: context.repo.owner, repo: context.repo.repo,
+                  issue_number: item.number,
+                  body: STALE_MSG(staleDays, closeDays),
+                });
+                core.info(`marked #${item.number} stale (age ${ageDays.toFixed(1)}d)`);
+                ops++;
+              } else if (isStale && ageDays < staleDays) {
+                try {
+                  await github.rest.issues.removeLabel({
+                    owner: context.repo.owner, repo: context.repo.repo,
+                    issue_number: item.number, name: STALE_LABEL,
+                  });
+                  core.info(`unstaled #${item.number} (real activity ${ageDays.toFixed(1)}d ago)`);
+                  ops++;
+                } catch (e) { if (e.status !== 404) throw e; }
+              } else if (isStale && ageDays >= staleDays + closeDays) {
+                await github.rest.issues.createComment({
+                  owner: context.repo.owner, repo: context.repo.repo,
+                  issue_number: item.number, body: CLOSE_MSG,
+                });
+                await github.rest.issues.update({
+                  owner: context.repo.owner, repo: context.repo.repo,
+                  issue_number: item.number, state: 'closed', state_reason: 'not_planned',
+                });
+                core.info(`closed #${item.number} (age ${ageDays.toFixed(1)}d)`);
+                ops++;
+              }
+            }