Skip to content

Commit 066a427

Browse files
authored
feat: add automated issue auto-close workflows with dry-run testing (#832)
* feat: add GitHub workflow for auto-closing stale issues with dry-run support - Daily workflow checks issues with configurable labels after X days - Removes label if unauthorized users comment, closes if only authorized users - Supports team-based or write-access authorization modes - Includes comprehensive input validation and error handling - Adds manual trigger with dry-run mode for safe testing * fix: Replace deprecated GitHub Search API with Issues API - Replace github.rest.search.issuesAndPullRequests with github.rest.issues.listForRepo - Add pagination support to handle repositories with many labeled issues * feat: remove label immediately on unauthorized comments - Check for unauthorized comments before time validation - Remove the label instantly when non-authorized users respond * feat: add optional replacement label when removing auto-close label - Add REPLACEMENT_LABEL environment variable for optional label substitution - Apply replacement label when unauthorized users comment and auto-close label is removed * feat: Consolidate auto-close workflows into a single matrix-based action - Merge auto-close-3-days.yml and auto-close-7-days.yml into auto-close.yml - Use a matrix strategy to handle both 3-day and 7-day label processing
1 parent 6a1b2d4 commit 066a427

File tree

1 file changed

+237
-0
lines changed

1 file changed

+237
-0
lines changed

.github/workflows/auto-close.yml

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
name: Auto Close Issues
2+
3+
on:
4+
schedule:
5+
- cron: '0 14 * * 1-5' # 9 AM EST (2 PM UTC) Monday through Friday
6+
workflow_dispatch:
7+
inputs:
8+
dry_run:
9+
description: 'Run in dry-run mode (no actions taken, only logging)'
10+
required: false
11+
default: 'false'
12+
type: boolean
13+
14+
jobs:
15+
auto-close:
16+
runs-on: ubuntu-latest
17+
strategy:
18+
matrix:
19+
include:
20+
- label: 'autoclose in 3 days'
21+
days: 3
22+
issue_types: 'issues' #issues/pulls/both
23+
replacement_label: ''
24+
closure_message: 'This issue has been automatically closed as it was marked for auto-closure by the team and no additional responses was received within 3 days.'
25+
dry_run: 'false'
26+
- label: 'autoclose in 7 days'
27+
days: 7
28+
issue_types: 'issues' # issues/pulls/both
29+
replacement_label: ''
30+
closure_message: 'This issue has been automatically closed as it was marked for auto-closure by the team and no additional responses was received within 7 days.'
31+
dry_run: 'false'
32+
steps:
33+
- name: Validate and process ${{ matrix.label }}
34+
uses: actions/github-script@v8
35+
env:
36+
LABEL_NAME: ${{ matrix.label }}
37+
DAYS_TO_WAIT: ${{ matrix.days }}
38+
AUTHORIZED_USERS: ''
39+
AUTH_MODE: 'write-access'
40+
ISSUE_TYPES: ${{ matrix.issue_types }}
41+
DRY_RUN: ${{ matrix.dry_run }}
42+
REPLACEMENT_LABEL: ${{ matrix.replacement_label }}
43+
CLOSE_MESSAGE: ${{matrix.closure_message}}
44+
with:
45+
script: |
46+
const REQUIRED_PERMISSIONS = ['write', 'admin'];
47+
const CLOSE_MESSAGE = process.env.CLOSE_MESSAGE;
48+
const isDryRun = '${{ inputs.dry_run }}' === 'true' || process.env.DRY_RUN === 'true';
49+
50+
const config = {
51+
labelName: process.env.LABEL_NAME,
52+
daysToWait: parseInt(process.env.DAYS_TO_WAIT),
53+
authMode: process.env.AUTH_MODE,
54+
authorizedUsers: process.env.AUTHORIZED_USERS?.split(',').map(u => u.trim()).filter(u => u) || [],
55+
issueTypes: process.env.ISSUE_TYPES,
56+
replacementLabel: process.env.REPLACEMENT_LABEL?.trim() || null
57+
};
58+
59+
console.log(`🏷️ Processing label: "${config.labelName}" (${config.daysToWait} days)`);
60+
if (isDryRun) console.log('🧪 DRY-RUN MODE: No actions will be taken');
61+
62+
const cutoffDate = new Date();
63+
cutoffDate.setDate(cutoffDate.getDate() - config.daysToWait);
64+
65+
async function isAuthorizedUser(username) {
66+
try {
67+
if (config.authMode === 'users') {
68+
return config.authorizedUsers.includes(username);
69+
} else if (config.authMode === 'write-access') {
70+
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
71+
owner: context.repo.owner,
72+
repo: context.repo.repo,
73+
username: username
74+
});
75+
return REQUIRED_PERMISSIONS.includes(data.permission);
76+
}
77+
} catch (error) {
78+
console.log(`⚠️ Failed to check authorization for ${username}: ${error.message}`);
79+
return false;
80+
}
81+
return false;
82+
}
83+
84+
let allIssues = [];
85+
let page = 1;
86+
87+
while (true) {
88+
const { data: issues } = await github.rest.issues.listForRepo({
89+
owner: context.repo.owner,
90+
repo: context.repo.repo,
91+
state: 'open',
92+
labels: config.labelName,
93+
sort: 'updated',
94+
direction: 'desc',
95+
per_page: 100,
96+
page: page
97+
});
98+
99+
if (issues.length === 0) break;
100+
allIssues = allIssues.concat(issues);
101+
if (issues.length < 100) break;
102+
page++;
103+
}
104+
105+
const targetIssues = allIssues.filter(issue => {
106+
if (config.issueTypes === 'issues' && issue.pull_request) return false;
107+
if (config.issueTypes === 'pulls' && !issue.pull_request) return false;
108+
return true;
109+
});
110+
111+
console.log(`🔍 Found ${targetIssues.length} items with label "${config.labelName}"`);
112+
113+
if (targetIssues.length === 0) {
114+
console.log('✅ No items to process');
115+
return;
116+
}
117+
118+
let closedCount = 0;
119+
let labelRemovedCount = 0;
120+
let skippedCount = 0;
121+
122+
for (const issue of targetIssues) {
123+
console.log(`\n📋 Processing #${issue.number}: ${issue.title}`);
124+
125+
try {
126+
const { data: events } = await github.rest.issues.listEvents({
127+
owner: context.repo.owner,
128+
repo: context.repo.repo,
129+
issue_number: issue.number
130+
});
131+
132+
const labelEvents = events
133+
.filter(e => e.event === 'labeled' && e.label?.name === config.labelName)
134+
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
135+
136+
if (labelEvents.length === 0) {
137+
console.log(`⚠️ No label events found for #${issue.number}`);
138+
skippedCount++;
139+
continue;
140+
}
141+
142+
const lastLabelAdded = new Date(labelEvents[0].created_at);
143+
const labelAdder = labelEvents[0].actor.login;
144+
145+
const { data: comments } = await github.rest.issues.listComments({
146+
owner: context.repo.owner,
147+
repo: context.repo.repo,
148+
issue_number: issue.number,
149+
since: lastLabelAdded.toISOString()
150+
});
151+
152+
let hasUnauthorizedComment = false;
153+
154+
for (const comment of comments) {
155+
if (comment.user.login === labelAdder) continue;
156+
157+
const isAuthorized = await isAuthorizedUser(comment.user.login);
158+
if (!isAuthorized) {
159+
console.log(`❌ New comment from ${comment.user.login}`);
160+
hasUnauthorizedComment = true;
161+
break;
162+
}
163+
}
164+
165+
if (hasUnauthorizedComment) {
166+
if (isDryRun) {
167+
console.log(`🧪 DRY-RUN: Would remove ${config.labelName} label from #${issue.number}`);
168+
if (config.replacementLabel) {
169+
console.log(`🧪 DRY-RUN: Would add ${config.replacementLabel} label to #${issue.number}`);
170+
}
171+
} else {
172+
await github.rest.issues.removeLabel({
173+
owner: context.repo.owner,
174+
repo: context.repo.repo,
175+
issue_number: issue.number,
176+
name: config.labelName
177+
});
178+
console.log(`🏷️ Removed ${config.labelName} label from #${issue.number}`);
179+
180+
if (config.replacementLabel) {
181+
await github.rest.issues.addLabels({
182+
owner: context.repo.owner,
183+
repo: context.repo.repo,
184+
issue_number: issue.number,
185+
labels: [config.replacementLabel]
186+
});
187+
console.log(`🏷️ Added ${config.replacementLabel} label to #${issue.number}`);
188+
}
189+
}
190+
labelRemovedCount++;
191+
continue;
192+
}
193+
194+
if (lastLabelAdded > cutoffDate) {
195+
const daysRemaining = Math.ceil((lastLabelAdded - cutoffDate) / (1000 * 60 * 60 * 24));
196+
console.log(`⏳ Label added too recently (${daysRemaining} days remaining)`);
197+
skippedCount++;
198+
continue;
199+
}
200+
201+
if (isDryRun) {
202+
console.log(`🧪 DRY-RUN: Would close #${issue.number} with comment`);
203+
} else {
204+
await github.rest.issues.createComment({
205+
owner: context.repo.owner,
206+
repo: context.repo.repo,
207+
issue_number: issue.number,
208+
body: CLOSE_MESSAGE
209+
});
210+
211+
await github.rest.issues.update({
212+
owner: context.repo.owner,
213+
repo: context.repo.repo,
214+
issue_number: issue.number,
215+
state: 'closed'
216+
});
217+
218+
console.log(`🔒 Closed #${issue.number}`);
219+
}
220+
closedCount++;
221+
} catch (error) {
222+
console.log(`❌ Error processing #${issue.number}: ${error.message}`);
223+
skippedCount++;
224+
}
225+
}
226+
227+
console.log(`\n📊 Summary for "${config.labelName}":`);
228+
if (isDryRun) {
229+
console.log(` 🧪 DRY-RUN MODE - No actual changes made:`);
230+
console.log(` • Issues that would be closed: ${closedCount}`);
231+
console.log(` • Labels that would be removed: ${labelRemovedCount}`);
232+
} else {
233+
console.log(` • Issues closed: ${closedCount}`);
234+
console.log(` • Labels removed: ${labelRemovedCount}`);
235+
}
236+
console.log(` • Issues skipped: ${skippedCount}`);
237+
console.log(` • Total processed: ${targetIssues.length}`);

0 commit comments

Comments
 (0)