Skip to content

Commit ee0d5f3

Browse files
committed
closer
1 parent 48474a4 commit ee0d5f3

File tree

1 file changed

+161
-0
lines changed

1 file changed

+161
-0
lines changed
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
name: Close Old Issues
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
days_old:
7+
description: 'Close issues older than N days'
8+
required: true
9+
default: '365'
10+
type: number
11+
dry_run:
12+
description: 'Dry run mode (preview only, do not close)'
13+
required: true
14+
default: true
15+
type: boolean
16+
comment_on_close:
17+
description: 'Add comment when closing issues'
18+
required: true
19+
default: true
20+
type: boolean
21+
22+
jobs:
23+
close-old-issues:
24+
runs-on: ubuntu-latest
25+
permissions:
26+
issues: write
27+
28+
steps:
29+
- name: Close or list old issues
30+
uses: actions/github-script@v8
31+
with:
32+
script: |
33+
const daysOld = ${{ inputs.days_old }};
34+
const dryRun = ${{ inputs.dry_run }};
35+
const addComment = ${{ inputs.comment_on_close }};
36+
const cutoffDate = new Date();
37+
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
38+
39+
console.log(`Looking for issues older than ${daysOld} days (before ${cutoffDate.toISOString()})`);
40+
console.log(`Dry run mode: ${dryRun ? 'YES (no changes will be made)' : 'NO (issues will be closed)'}`);
41+
console.log('---');
42+
43+
let page = 1;
44+
let issuesClosed = 0;
45+
let issuesToClose = [];
46+
47+
while (true) {
48+
const issues = await github.rest.issues.listForRepo({
49+
owner: context.repo.owner,
50+
repo: context.repo.repo,
51+
state: 'open',
52+
sort: 'created',
53+
direction: 'asc',
54+
per_page: 100,
55+
page: page
56+
});
57+
58+
if (issues.data.length === 0) {
59+
break;
60+
}
61+
62+
for (const issue of issues.data) {
63+
// Skip pull requests
64+
if (issue.pull_request) {
65+
continue;
66+
}
67+
68+
const createdAt = new Date(issue.created_at);
69+
const updatedAt = new Date(issue.updated_at);
70+
71+
// Check if issue is old enough based on last update
72+
if (updatedAt < cutoffDate) {
73+
const daysOldCalculated = Math.floor((Date.now() - updatedAt.getTime()) / (1000 * 60 * 60 * 24));
74+
75+
issuesToClose.push({
76+
number: issue.number,
77+
title: issue.title,
78+
created_at: createdAt.toISOString().split('T')[0],
79+
updated_at: updatedAt.toISOString().split('T')[0],
80+
days_old: daysOldCalculated,
81+
url: issue.html_url
82+
});
83+
}
84+
}
85+
86+
page++;
87+
}
88+
89+
console.log(`\nFound ${issuesToClose.length} issue(s) to close:\n`);
90+
91+
for (const issue of issuesToClose) {
92+
console.log(`#${issue.number}: ${issue.title}`);
93+
console.log(` Created: ${issue.created_at}`);
94+
console.log(` Last updated: ${issue.updated_at} (${issue.days_old} days ago)`);
95+
console.log(` URL: ${issue.url}`);
96+
console.log('');
97+
98+
if (!dryRun) {
99+
try {
100+
// Add a comment before closing (if enabled)
101+
if (addComment) {
102+
await github.rest.issues.createComment({
103+
owner: context.repo.owner,
104+
repo: context.repo.repo,
105+
issue_number: issue.number,
106+
body: `This issue has been automatically closed due to inactivity (no updates for ${issue.days_old} days). If you believe this issue is still relevant, please feel free to reopen it or create a new issue.`
107+
});
108+
}
109+
110+
// Close the issue
111+
await github.rest.issues.update({
112+
owner: context.repo.owner,
113+
repo: context.repo.repo,
114+
issue_number: issue.number,
115+
state: 'closed',
116+
state_reason: 'not_planned'
117+
});
118+
119+
issuesClosed++;
120+
console.log(` ✓ Closed issue #${issue.number}`);
121+
} catch (error) {
122+
console.error(` ✗ Failed to close issue #${issue.number}: ${error.message}`);
123+
}
124+
}
125+
}
126+
127+
console.log('\n---');
128+
if (dryRun) {
129+
console.log(`DRY RUN: Would close ${issuesToClose.length} issue(s)`);
130+
console.log('To actually close these issues, run this workflow again with dry_run set to false');
131+
} else {
132+
console.log(`Successfully closed ${issuesClosed} out of ${issuesToClose.length} issue(s)`);
133+
}
134+
135+
// Set output for summary
136+
core.summary
137+
.addHeading(dryRun ? 'Dry Run Results' : 'Close Old Issues Results')
138+
.addRaw(`**Mode:** ${dryRun ? '🔍 Dry Run (Preview Only)' : '✅ Live Run'}\n`)
139+
.addRaw(`**Cutoff Date:** Issues last updated before ${cutoffDate.toISOString().split('T')[0]}\n`)
140+
.addRaw(`**Days Old Threshold:** ${daysOld} days\n`)
141+
.addRaw(`**Issues ${dryRun ? 'Found' : 'Closed'}:** ${dryRun ? issuesToClose.length : issuesClosed}\n\n`);
142+
143+
if (issuesToClose.length > 0) {
144+
core.summary.addHeading('Issues', 3);
145+
const tableData = issuesToClose.map(issue => [
146+
`#${issue.number}`,
147+
issue.title.substring(0, 80) + (issue.title.length > 80 ? '...' : ''),
148+
issue.updated_at,
149+
`${issue.days_old} days`,
150+
`[View](${issue.url})`
151+
]);
152+
153+
core.summary.addTable([
154+
['Issue', 'Title', 'Last Updated', 'Age', 'Link'],
155+
...tableData
156+
]);
157+
} else {
158+
core.summary.addRaw('\nNo issues found matching the criteria.');
159+
}
160+
161+
await core.summary.write();

0 commit comments

Comments
 (0)