Skip to content

Commit bb2c5c1

Browse files
liliu-zclaude
andauthored
ci: add workflow to auto-close stale issues (#730)
Auto-close issues where the last comment is from a team member (zilliztech org) and has had no response for over 30 days. Issues with the `keep-open` label are exempt from auto-closure. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f35f648 commit bb2c5c1

1 file changed

Lines changed: 92 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
name: Close Stale Issues
2+
3+
on:
4+
schedule:
5+
# Run every day at 02:00 UTC
6+
- cron: '0 2 * * *'
7+
workflow_dispatch: {}
8+
9+
jobs:
10+
close-stale-issues:
11+
runs-on: ubuntu-latest
12+
permissions:
13+
issues: write
14+
steps:
15+
- uses: actions/github-script@v7
16+
with:
17+
script: |
18+
const STALE_DAYS = 30;
19+
const EXEMPT_LABEL = 'keep-open';
20+
// MEMBER = org member, OWNER = org owner
21+
const TEAM_ASSOCIATIONS = ['MEMBER', 'OWNER'];
22+
23+
const now = new Date();
24+
const staleThreshold = new Date(now.getTime() - STALE_DAYS * 24 * 60 * 60 * 1000);
25+
26+
console.log(`Looking for issues with last team reply before ${staleThreshold.toISOString()}`);
27+
28+
const issues = await github.paginate(github.rest.issues.listForRepo, {
29+
owner: context.repo.owner,
30+
repo: context.repo.repo,
31+
state: 'open',
32+
sort: 'updated',
33+
direction: 'asc',
34+
per_page: 100,
35+
});
36+
37+
let closedCount = 0;
38+
39+
for (const issue of issues) {
40+
// Skip pull requests (GitHub API returns PRs as issues too)
41+
if (issue.pull_request) continue;
42+
43+
// Skip issues with the exempt label
44+
if (issue.labels.some(l => l.name === EXEMPT_LABEL)) {
45+
console.log(`Skipping #${issue.number} (has '${EXEMPT_LABEL}' label)`);
46+
continue;
47+
}
48+
49+
// Get all comments for this issue
50+
const comments = await github.paginate(github.rest.issues.listComments, {
51+
owner: context.repo.owner,
52+
repo: context.repo.repo,
53+
issue_number: issue.number,
54+
per_page: 100,
55+
});
56+
57+
// Skip issues with no comments
58+
if (comments.length === 0) continue;
59+
60+
const lastComment = comments[comments.length - 1];
61+
const lastCommentDate = new Date(lastComment.created_at);
62+
const isFromTeam = TEAM_ASSOCIATIONS.includes(lastComment.author_association);
63+
const isStale = lastCommentDate < staleThreshold;
64+
65+
if (isFromTeam && isStale) {
66+
console.log(`Closing #${issue.number}: last team reply on ${lastCommentDate.toISOString()} by @${lastComment.user.login}`);
67+
68+
await github.rest.issues.createComment({
69+
owner: context.repo.owner,
70+
repo: context.repo.repo,
71+
issue_number: issue.number,
72+
body: [
73+
'This issue has been automatically closed because it has not received a response for over 30 days since the last reply from a team member.',
74+
'',
75+
'If this issue is still relevant, feel free to reopen it or create a new issue.',
76+
'You can also add the `keep-open` label to prevent automatic closure.',
77+
].join('\n'),
78+
});
79+
80+
await github.rest.issues.update({
81+
owner: context.repo.owner,
82+
repo: context.repo.repo,
83+
issue_number: issue.number,
84+
state: 'closed',
85+
state_reason: 'not_planned',
86+
});
87+
88+
closedCount++;
89+
}
90+
}
91+
92+
console.log(`Done. Closed ${closedCount} stale issue(s).`);

0 commit comments

Comments
 (0)