stale.yml

  1name: "Stale issues and PRs"
  2
  3on:
  4  schedule:
  5    - cron: "0 2 * * *"
  6  workflow_dispatch:
  7
  8permissions:
  9  issues: write
 10  pull-requests: write
 11
 12jobs:
 13  stale:
 14    runs-on: ubuntu-latest
 15    steps:
 16      - uses: actions/github-script@v9
 17        with:
 18          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
 19          script: |
 20            const IGNORED_AUTHORS = new Set(['floatpanebot', 'github-actions[bot]', 'renovate[bot]', 'dependabot[bot]']);
 21            const STALE_LABEL = 'stale';
 22            const EXEMPT_ISSUE = ['pinned', 'security', 'help-wanted', 'good-first-issue', 'in-progress'];
 23            const EXEMPT_PR = ['pinned', 'security', 'in-progress', 'blocked'];
 24            const ISSUE_STALE_DAYS = 30;
 25            const ISSUE_CLOSE_DAYS = 14;
 26            const PR_STALE_DAYS = 45;
 27            const PR_CLOSE_DAYS = 21;
 28            const OPS_LIMIT = 100;
 29
 30            const STALE_MSG = (days, close) =>
 31              `This has had no activity for ${days} days. It will be closed in ${close} days unless updated. ` +
 32              `Comment or remove the \`stale\` label to keep it open.`;
 33            const CLOSE_MSG = 'Closing due to inactivity. Reopen if still relevant.';
 34
 35            const now = Date.now();
 36            let ops = 0;
 37
 38            async function lastHumanActivity(issue) {
 39              let latest = new Date(issue.created_at).getTime();
 40              const comments = await github.paginate(github.rest.issues.listComments, {
 41                owner: context.repo.owner,
 42                repo: context.repo.repo,
 43                issue_number: issue.number,
 44                per_page: 100,
 45              });
 46              for (const c of comments) {
 47                if (c.user && IGNORED_AUTHORS.has(c.user.login)) continue;
 48                const t = new Date(c.created_at).getTime();
 49                if (t > latest) latest = t;
 50              }
 51              if (issue.pull_request) {
 52                const commits = await github.paginate(github.rest.pulls.listCommits, {
 53                  owner: context.repo.owner,
 54                  repo: context.repo.repo,
 55                  pull_number: issue.number,
 56                  per_page: 100,
 57                });
 58                for (const c of commits) {
 59                  const t = new Date(c.commit.author.date).getTime();
 60                  if (t > latest) latest = t;
 61                }
 62              }
 63              return latest;
 64            }
 65
 66            const items = await github.paginate(github.rest.issues.listForRepo, {
 67              owner: context.repo.owner,
 68              repo: context.repo.repo,
 69              state: 'open',
 70              per_page: 100,
 71            });
 72
 73            for (const item of items) {
 74              if (ops >= OPS_LIMIT) { core.info('ops limit hit, stopping'); break; }
 75              const isPR = !!item.pull_request;
 76              const exempt = isPR ? EXEMPT_PR : EXEMPT_ISSUE;
 77              const labels = item.labels.map(l => l.name || l);
 78              if (exempt.some(l => labels.includes(l))) continue;
 79
 80              const staleDays = isPR ? PR_STALE_DAYS : ISSUE_STALE_DAYS;
 81              const closeDays = isPR ? PR_CLOSE_DAYS : ISSUE_CLOSE_DAYS;
 82              const isStale = labels.includes(STALE_LABEL);
 83
 84              const lastTs = await lastHumanActivity(item);
 85              const ageDays = (now - lastTs) / 86400000;
 86
 87              if (!isStale && ageDays >= staleDays) {
 88                await github.rest.issues.addLabels({
 89                  owner: context.repo.owner, repo: context.repo.repo,
 90                  issue_number: item.number, labels: [STALE_LABEL],
 91                });
 92                await github.rest.issues.createComment({
 93                  owner: context.repo.owner, repo: context.repo.repo,
 94                  issue_number: item.number,
 95                  body: STALE_MSG(staleDays, closeDays),
 96                });
 97                core.info(`marked #${item.number} stale (age ${ageDays.toFixed(1)}d)`);
 98                ops++;
 99              } else if (isStale && ageDays < staleDays) {
100                try {
101                  await github.rest.issues.removeLabel({
102                    owner: context.repo.owner, repo: context.repo.repo,
103                    issue_number: item.number, name: STALE_LABEL,
104                  });
105                  core.info(`unstaled #${item.number} (real activity ${ageDays.toFixed(1)}d ago)`);
106                  ops++;
107                } catch (e) { if (e.status !== 404) throw e; }
108              } else if (isStale && ageDays >= staleDays + closeDays) {
109                await github.rest.issues.createComment({
110                  owner: context.repo.owner, repo: context.repo.repo,
111                  issue_number: item.number, body: CLOSE_MSG,
112                });
113                await github.rest.issues.update({
114                  owner: context.repo.owner, repo: context.repo.repo,
115                  issue_number: item.number, state: 'closed', state_reason: 'not_planned',
116                });
117                core.info(`closed #${item.number} (age ${ageDays.toFixed(1)}d)`);
118                ops++;
119              }
120            }