@@ -13,25 +13,108 @@ jobs:
1313 stale :
1414 runs-on : ubuntu-latest
1515 steps :
16- - uses : actions/stale @v9
16+ - uses : actions/github-script @v9
1717 with :
18- repo-token : ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
19- days-before-issue-stale : 30
20- days-before-issue-close : 14
21- days-before-pr-stale : 45
22- days-before-pr-close : 21
23- stale-issue-label : " stale"
24- stale-pr-label : " stale"
25- exempt-issue-labels : " pinned,security,help-wanted,good-first-issue,in-progress"
26- exempt-pr-labels : " pinned,security,in-progress,blocked"
27- stale-issue-message : >
28- This issue has had no activity for 30 days. It will be closed in 14 days unless updated.
29- Add a comment or remove the `stale` label to keep it open.
30- stale-pr-message : >
31- This PR has had no activity for 45 days. It will be closed in 21 days unless updated.
32- close-issue-message : >
33- Closing due to inactivity. Reopen if still relevant.
34- close-pr-message : >
35- Closing due to inactivity. Reopen if still relevant.
36- remove-stale-when-updated : true
37- operations-per-run : 100
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+ }
0 commit comments