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 }