Auto-unassign inactive contributors #36
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 inactive contributors | |
| on: | |
| schedule: | |
| - cron: "0 2 * * *" # daily at 02:00 UTC | |
| workflow_dispatch: | |
| permissions: | |
| issues: write | |
| jobs: | |
| unassign: | |
| name: Unassign after 7 days with no PR | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check assigned issues and unassign stale ones | |
| uses: actions/github-script@v9 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const DAYS_LIMIT = 7; | |
| const MS_LIMIT = DAYS_LIMIT * 24 * 60 * 60 * 1000; | |
| const now = Date.now(); | |
| // All open issues with gssoc:assigned label | |
| const issues = await github.paginate(github.rest.issues.listForRepo, { | |
| owner, repo, state: 'open', labels: 'gssoc:assigned', per_page: 100, | |
| }); | |
| for (const issue of issues) { | |
| if (issue.pull_request) continue; // skip PRs in results | |
| if (!issue.assignees || issue.assignees.length === 0) { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, repo, issue_number: issue.number, name: 'gssoc:assigned', | |
| }); | |
| } catch { | |
| // Label already removed — ignore | |
| } | |
| continue; | |
| } | |
| // Find when this issue was last assigned via events | |
| const events = await github.paginate( | |
| "GET /repos/{owner}/{repo}/issues/{issue_number}/events", | |
| { owner, repo, issue_number: issue.number, per_page: 100 } | |
| ); | |
| const assignedEvents = events | |
| .filter(e => e.event === 'assigned') | |
| .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); | |
| if (assignedEvents.length === 0) continue; | |
| const assignedAt = new Date(assignedEvents[0].created_at).getTime(); | |
| if (now - assignedAt < MS_LIMIT) continue; // still within deadline | |
| const assignees = issue.assignees.map(a => a.login); | |
| // Check if any PR references this issue (via timeline cross-references) | |
| const timeline = await github.paginate( | |
| "GET /repos/{owner}/{repo}/issues/{issue_number}/timeline", | |
| { owner, repo, issue_number: issue.number, per_page: 100 }, | |
| ); | |
| const hasLinkedPR = timeline.some(event => { | |
| if (event.event !== 'cross-referenced') return false; | |
| const pr = event.source?.issue; | |
| if (!pr || !pr.pull_request) return false; | |
| // Only count open or successfully merged PRs (closed without merging shouldn't block) | |
| const isPRActive = pr.state === 'open' || pr.pull_request.merged_at != null; | |
| if (!isPRActive) return false; | |
| const prCreator = pr.user?.login; | |
| return assignees.includes(prCreator); | |
| }); | |
| if (hasLinkedPR) continue; // contributor opened a PR — leave them alone | |
| // Unassign | |
| await github.rest.issues.removeAssignees({ | |
| owner, repo, issue_number: issue.number, assignees, | |
| }); | |
| // Remove gssoc:assigned label | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, repo, issue_number: issue.number, name: 'gssoc:assigned', | |
| }); | |
| } catch { | |
| // Label already removed — ignore | |
| } | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: issue.number, | |
| body: `Hey @${assignees.join(', @')} — this issue was assigned ${DAYS_LIMIT} days ago with no PR opened. Unassigning so others can pick it up.\n\nStill working on it? Drop a comment and request re-assignment.`, | |
| }); | |
| console.log(`Unassigned #${issue.number} from ${assignees.join(', ')}`); | |
| } |