Auto-unassign stale issues #150
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
| name: Auto-unassign stale issues | |
| on: | |
| schedule: | |
| # Run daily at 6:00 AM UTC | |
| - cron: '0 6 * * *' | |
| workflow_dispatch: | |
| # Allow manual triggering for testing | |
| inputs: | |
| days_threshold: | |
| description: 'Days threshold for unassignment' | |
| required: false | |
| default: '7' | |
| type: string | |
| permissions: | |
| issues: write | |
| pull-requests: read | |
| contents: read | |
| jobs: | |
| unassign-stale-issues: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check stale assigned issues | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const daysThreshold = parseInt(process.env.DAYS_THRESHOLD || '${{ inputs.days_threshold || '7' }}'); | |
| const millisecondsThreshold = daysThreshold * 24 * 60 * 60 * 1000; | |
| console.log(`🔍 Checking for issues assigned more than ${daysThreshold} days ago without PRs...`); | |
| // Get all open issues with assignees | |
| const { data: issues } = await github.rest.issues.listForRepo({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| assignee: '*', | |
| per_page: 100 | |
| }); | |
| console.log(`📋 Found ${issues.length} assigned open issues to check`); | |
| let processedCount = 0; | |
| let unassignedCount = 0; | |
| for (const issue of issues) { | |
| // Skip if it's a pull request (issues and PRs share the same API) | |
| if (issue.pull_request) { | |
| continue; | |
| } | |
| processedCount++; | |
| console.log(`\n📝 Processing issue #${issue.number}: "${issue.title}"`); | |
| // Get assignment events to find when the issue was assigned | |
| const { data: events } = await github.rest.issues.listEvents({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| per_page: 100 | |
| }); | |
| // Find the latest assignment event | |
| const assignmentEvents = events | |
| .filter(event => event.event === 'assigned') | |
| .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); | |
| if (assignmentEvents.length === 0) { | |
| console.log(`⚠️ No assignment events found for issue #${issue.number}`); | |
| continue; | |
| } | |
| const latestAssignment = assignmentEvents[0]; | |
| const assignedDate = new Date(latestAssignment.created_at); | |
| const now = new Date(); | |
| const timeDiff = now - assignedDate; | |
| console.log(`📅 Issue #${issue.number} assigned ${Math.floor(timeDiff / (24 * 60 * 60 * 1000))} days ago to @${latestAssignment.assignee?.login || 'unknown'}`); | |
| // Check if issue was assigned more than threshold days ago | |
| if (timeDiff < millisecondsThreshold) { | |
| console.log(`✅ Issue #${issue.number} is within the ${daysThreshold}-day threshold`); | |
| continue; | |
| } | |
| // Search for PRs that reference this issue | |
| const searchQueries = [ | |
| `is:pr repo:${context.repo.owner}/${context.repo.repo} ${issue.number}`, | |
| `is:pr repo:${context.repo.owner}/${context.repo.repo} "fixes #${issue.number}"`, | |
| `is:pr repo:${context.repo.owner}/${context.repo.repo} "closes #${issue.number}"`, | |
| `is:pr repo:${context.repo.owner}/${context.repo.repo} "resolves #${issue.number}"` | |
| ]; | |
| let hasPR = false; | |
| for (const query of searchQueries) { | |
| try { | |
| const { data: searchResults } = await github.rest.search.issuesAndPullRequests({ | |
| q: query, | |
| per_page: 10 | |
| }); | |
| if (searchResults.total_count > 0) { | |
| hasPR = true; | |
| console.log(`🔗 Found ${searchResults.total_count} PR(s) referencing issue #${issue.number}`); | |
| break; | |
| } | |
| } catch (error) { | |
| console.log(`⚠️ Search error for "${query}": ${error.message}`); | |
| } | |
| } | |
| if (hasPR) { | |
| console.log(`✅ Issue #${issue.number} has associated PR(s), keeping assignment`); | |
| continue; | |
| } | |
| // No PR found and issue is stale - unassign | |
| console.log(`🚨 Issue #${issue.number} has no associated PRs and is stale - proceeding with unassignment`); | |
| try { | |
| // Get current assignees | |
| const assignees = issue.assignees.map(assignee => assignee.login); | |
| // Unassign all assignees | |
| await github.rest.issues.removeAssignees({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| assignees: assignees | |
| }); | |
| // Add explanatory comment | |
| const assigneesList = assignees.map(username => `@${username}`).join(', '); | |
| const commentBody = [ | |
| '🤖 **Auto-unassignment Notice**', | |
| '', | |
| `This issue has been automatically unassigned from ${assigneesList} because it was assigned more than ${daysThreshold} days ago (on ${assignedDate.toDateString()}) and no pull request has been opened to address it.`, | |
| '', | |
| '**What happens next?**', | |
| '- This issue is now available for anyone to pick up', | |
| '- If you were working on this and need more time, please comment on this issue and ask to be reassigned', | |
| '- If you have a draft PR or work in progress, please open a draft PR to maintain your assignment', | |
| '', | |
| '**For future reference:**', | |
| `- Please open a draft PR within ${daysThreshold} days of being assigned to an issue`, | |
| '- This helps keep the project moving and ensures issues don\'t get stuck', | |
| '', | |
| 'Thank you for your understanding! 🙏' | |
| ].join('\n'); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| body: commentBody | |
| }); | |
| unassignedCount++; | |
| console.log(`✅ Successfully unassigned and commented on issue #${issue.number}`); | |
| } catch (error) { | |
| console.log(`❌ Failed to unassign issue #${issue.number}: ${error.message}`); | |
| } | |
| // Add a small delay to avoid rate limiting | |
| await new Promise(resolve => setTimeout(resolve, 1000)); | |
| } | |
| console.log(`\n📊 Summary: Processed ${processedCount} issues, unassigned ${unassignedCount} stale issues`); | |
| // Create a summary comment if any issues were unassigned | |
| if (unassignedCount > 0) { | |
| const summaryBody = [ | |
| '## Daily Auto-unassignment Report', | |
| '', | |
| `📅 **Date**: ${new Date().toDateString()}`, | |
| `🔢 **Issues Processed**: ${processedCount}`, | |
| `🚨 **Issues Unassigned**: ${unassignedCount}`, | |
| `⏰ **Threshold**: ${daysThreshold} days`, | |
| '', | |
| '### What was done?', | |
| '- Checked all open assigned issues', | |
| `- Unassigned issues that were assigned more than ${daysThreshold} days ago without associated PRs`, | |
| '- Added explanatory comments to unassigned issues', | |
| '', | |
| '### Why does this happen?', | |
| 'This automation helps keep the project moving by:', | |
| '- Preventing issues from getting stuck with inactive assignees', | |
| '- Making issues available for other contributors', | |
| '- Encouraging timely progress or communication', | |
| '', | |
| '*This is an automated report. The issues have been unassigned and are now available for anyone to work on.*' | |
| ].join('\n'); | |
| const summaryIssue = await github.rest.issues.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title: `🤖 Auto-unassignment Summary - ${new Date().toDateString()}`, | |
| body: summaryBody, | |
| labels: ['automation', 'maintenance'] | |
| }); | |
| console.log(`📋 Created summary issue #${summaryIssue.data.number}`); | |
| } | |
| env: | |
| DAYS_THRESHOLD: ${{ inputs.days_threshold || '7' }} |