Skip to content

Auto-unassign inactive contributors #36

Auto-unassign inactive contributors

Auto-unassign inactive contributors #36

Workflow file for this run

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(', ')}`);
}