|
| 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