name: Auto Close Issues on: schedule: - cron: '0 14 * * 1-5' # 9 AM EST (2 PM UTC) Monday through Friday workflow_dispatch: inputs: dry_run: description: 'Run in dry-run mode (no actions taken, only logging)' required: false default: 'false' type: boolean permissions: contents: read issues: write pull-requests: write jobs: auto-close: runs-on: ubuntu-latest strategy: matrix: include: - label: 'autoclose in 3 days' days: 3 issue_types: 'both' #issues/pulls/both replacement_label: '' 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.' dry_run: 'false' - label: 'autoclose in 7 days' days: 7 issue_types: 'both' # issues/pulls/both replacement_label: '' 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.' dry_run: 'false' steps: - name: Validate and process ${{ matrix.label }} uses: actions/github-script@v8 env: LABEL_NAME: ${{ matrix.label }} DAYS_TO_WAIT: ${{ matrix.days }} AUTHORIZED_USERS: '' AUTH_MODE: 'write-access' ISSUE_TYPES: ${{ matrix.issue_types }} DRY_RUN: ${{ matrix.dry_run }} REPLACEMENT_LABEL: ${{ matrix.replacement_label }} CLOSE_MESSAGE: ${{matrix.closure_message}} with: script: | const REQUIRED_PERMISSIONS = ['write', 'admin']; const CLOSE_MESSAGE = process.env.CLOSE_MESSAGE; const isDryRun = '${{ inputs.dry_run }}' === 'true' || process.env.DRY_RUN === 'true'; const config = { labelName: process.env.LABEL_NAME, daysToWait: parseInt(process.env.DAYS_TO_WAIT), authMode: process.env.AUTH_MODE, authorizedUsers: process.env.AUTHORIZED_USERS?.split(',').map(u => u.trim()).filter(u => u) || [], issueTypes: process.env.ISSUE_TYPES, replacementLabel: process.env.REPLACEMENT_LABEL?.trim() || null }; console.log(`šŸ·ļø Processing label: "${config.labelName}" (${config.daysToWait} days)`); if (isDryRun) console.log('🧪 DRY-RUN MODE: No actions will be taken'); const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - config.daysToWait); async function isAuthorizedUser(username) { try { if (config.authMode === 'users') { return config.authorizedUsers.includes(username); } else if (config.authMode === 'write-access') { const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner: context.repo.owner, repo: context.repo.repo, username: username }); return REQUIRED_PERMISSIONS.includes(data.permission); } } catch (error) { console.log(`āš ļø Failed to check authorization for ${username}: ${error.message}`); return false; } return false; } let allIssues = []; let page = 1; while (true) { const { data: issues } = await github.rest.issues.listForRepo({ owner: context.repo.owner, repo: context.repo.repo, state: 'open', labels: config.labelName, sort: 'updated', direction: 'desc', per_page: 100, page: page }); if (issues.length === 0) break; allIssues = allIssues.concat(issues); if (issues.length < 100) break; page++; } const targetIssues = allIssues.filter(issue => { if (config.issueTypes === 'issues' && issue.pull_request) return false; if (config.issueTypes === 'pulls' && !issue.pull_request) return false; return true; }); console.log(`šŸ” Found ${targetIssues.length} items with label "${config.labelName}"`); if (targetIssues.length === 0) { console.log('āœ… No items to process'); return; } let closedCount = 0; let labelRemovedCount = 0; let skippedCount = 0; for (const issue of targetIssues) { console.log(`\nšŸ“‹ Processing #${issue.number}: ${issue.title}`); try { const { data: events } = await github.rest.issues.listEvents({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number }); const labelEvents = events .filter(e => e.event === 'labeled' && e.label?.name === config.labelName) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); if (labelEvents.length === 0) { console.log(`āš ļø No label events found for #${issue.number}`); skippedCount++; continue; } const lastLabelAdded = new Date(labelEvents[0].created_at); const labelAdder = labelEvents[0].actor.login; const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, since: lastLabelAdded.toISOString() }); let hasUnauthorizedComment = false; for (const comment of comments) { if (comment.user.login === labelAdder) continue; const isAuthorized = await isAuthorizedUser(comment.user.login); if (!isAuthorized) { console.log(`āŒ New comment from ${comment.user.login}`); hasUnauthorizedComment = true; break; } } if (hasUnauthorizedComment) { if (isDryRun) { console.log(`🧪 DRY-RUN: Would remove ${config.labelName} label from #${issue.number}`); if (config.replacementLabel) { console.log(`🧪 DRY-RUN: Would add ${config.replacementLabel} label to #${issue.number}`); } } else { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, name: config.labelName }); console.log(`šŸ·ļø Removed ${config.labelName} label from #${issue.number}`); if (config.replacementLabel) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, labels: [config.replacementLabel] }); console.log(`šŸ·ļø Added ${config.replacementLabel} label to #${issue.number}`); } } labelRemovedCount++; continue; } if (lastLabelAdded > cutoffDate) { const daysRemaining = Math.ceil((lastLabelAdded - cutoffDate) / (1000 * 60 * 60 * 24)); console.log(`ā³ Label added too recently (${daysRemaining} days remaining)`); skippedCount++; continue; } if (isDryRun) { console.log(`🧪 DRY-RUN: Would close #${issue.number} with comment`); } else { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, body: CLOSE_MESSAGE }); await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, state: 'closed' }); console.log(`šŸ”’ Closed #${issue.number}`); } closedCount++; } catch (error) { console.log(`āŒ Error processing #${issue.number}: ${error.message}`); skippedCount++; } } console.log(`\nšŸ“Š Summary for "${config.labelName}":`); if (isDryRun) { console.log(` 🧪 DRY-RUN MODE - No actual changes made:`); console.log(` • Issues that would be closed: ${closedCount}`); console.log(` • Labels that would be removed: ${labelRemovedCount}`); } else { console.log(` • Issues closed: ${closedCount}`); console.log(` • Labels removed: ${labelRemovedCount}`); } console.log(` • Issues skipped: ${skippedCount}`); console.log(` • Total processed: ${targetIssues.length}`);