generated from amazon-archives/__template_Apache-2.0
-
Notifications
You must be signed in to change notification settings - Fork 455
feat: add automated issue auto-close workflows with dry-run testing #832
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 17 commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
acf4961
feat: add GitHub workflow for auto-closing stale issues with dry-run …
yonib05 23f7457
fix: replace deprecated GitHub Search API with Issues API
yonib05 cc6989d
fix: resolve JavaScript syntax error in GitHub workflow
yonib05 7a99388
feat: remove label immediately on unauthorized comments
yonib05 99f4c93
feat: add optional replacement label when removing auto-close label
yonib05 b478b08
chore: Update Name of GitHub Action
yonib05 04504ad
chore: Testing closure by setting to one day
yonib05 6a70cdc
chore: Testing closure by setting to zero days
yonib05 f3c2246
fix: Allow for zero days
yonib05 860605f
chore: Test removal and replacement label
yonib05 b87e171
fix: Fix to allow manual dispatch inputs take presence over set env v…
yonib05 fd7ef16
fix: Fix to allow manual dispatch inputs take presence over set env v…
yonib05 e81388c
feat: Allow a closure message to be passed as an env variable
yonib05 5b543e2
chore: Prep for PR
yonib05 b9281ee
feature: Add autoclose 7 days
yonib05 13def8d
feat: Consolidate auto-close workflows into single matrix-based action
yonib05 6a99002
fix: Updated Comment syntax for yaml
yonib05 75a84c8
Create test.yml
yonib05 f49434b
Delete .github/workflows/test.yml
yonib05 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| 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 | ||
|
|
||
| jobs: | ||
| auto-close: | ||
| runs-on: ubuntu-latest | ||
| strategy: | ||
| matrix: | ||
| include: | ||
| - label: 'autoclose in 3 days' | ||
| days: 3 | ||
| issue_types: 'issues' #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: 'issues' # 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') { | ||
yonib05 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) { | ||
Unshure marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| await github.rest.issues.addLabels({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| issue_number: issue.number, | ||
| labels: [config.replacementLabel] | ||
Unshure marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }); | ||
| 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}`); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.