Skip to content

Auto-unassign stale PR assignees #750

Auto-unassign stale PR assignees

Auto-unassign stale PR assignees #750

name: Auto-unassign stale PR assignees
on:
schedule:
- cron: "*/15 * * * *" # run every 15 minutes
workflow_dispatch:
inputs:
enabled:
description: "Enable this automation"
type: boolean
default: true
max_age_minutes:
description: "Unassign if assigned longer than X minutes"
type: number
default: 60
dry_run:
description: "Preview only; do not change assignees"
type: boolean
default: false
permissions:
pull-requests: write
issues: write
env:
# Defaults (can be overridden via workflow_dispatch inputs)
ENABLED: "true"
MAX_ASSIGN_AGE_MINUTES: "60"
DRY_RUN: "false"
jobs:
sweep:
runs-on: ubuntu-latest
steps:
- name: Resolve inputs into env
run: |
# Prefer manual run inputs when present
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "ENABLED=${{ inputs.enabled }}" >> $GITHUB_ENV
echo "MAX_ASSIGN_AGE_MINUTES=${{ inputs.max_age_minutes }}" >> $GITHUB_ENV
echo "DRY_RUN=${{ inputs.dry_run }}" >> $GITHUB_ENV
fi
echo "Effective config: ENABLED=$ENABLED, MAX_ASSIGN_AGE_MINUTES=$MAX_ASSIGN_AGE_MINUTES, DRY_RUN=$DRY_RUN"
- name: Exit if disabled
if: ${{ env.ENABLED != 'true' && env.ENABLED != 'True' && env.ENABLED != 'TRUE' }}
run: echo "Disabled via ENABLED=$ENABLED. Exiting." && exit 0
- name: Unassign stale assignees
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const MAX_MIN = parseInt(process.env.MAX_ASSIGN_AGE_MINUTES || "60", 10);
const DRY_RUN = ["true","True","TRUE","1","yes"].includes(String(process.env.DRY_RUN));
const now = new Date();
core.info(`Scanning open PRs. Threshold = ${MAX_MIN} minutes. DRY_RUN=${DRY_RUN}`);
// List all open PRs
const prs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: "open", per_page: 100
});
let totalUnassigned = 0;
for (const pr of prs) {
if (!pr.assignees || pr.assignees.length === 0) continue;
const number = pr.number;
core.info(`PR #${number}: "${pr.title}" — assignees: ${pr.assignees.map(a => a.login).join(", ")}`);
// Pull reviews (to see if an assignee started a review)
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner, repo, pull_number: number, per_page: 100
});
// Issue comments (general comments)
const issueComments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: number, per_page: 100
});
// Review comments (file-level)
const reviewComments = await github.paginate(github.rest.pulls.listReviewComments, {
owner, repo, pull_number: number, per_page: 100
});
// Issue events (to find assignment timestamps)
const issueEvents = await github.paginate(github.rest.issues.listEvents, {
owner, repo, issue_number: number, per_page: 100
});
for (const a of pr.assignees) {
const assignee = a.login;
// Find the most recent "assigned" event for this assignee
const assignedEvents = issueEvents
.filter(e => e.event === "assigned" && e.assignee && e.assignee.login === assignee)
.sort((x, y) => new Date(y.created_at) - new Date(x.created_at));
if (assignedEvents.length === 0) {
core.info(` - @${assignee}: no 'assigned' event found; skipping.`);
continue;
}
const assignedAt = new Date(assignedEvents[0].created_at);
const ageMin = (now - assignedAt) / 60000;
// Has the assignee commented (issue or review comments) or reviewed?
const hasIssueComment = issueComments.some(c => c.user?.login === assignee);
const hasReviewComment = reviewComments.some(c => c.user?.login === assignee);
const hasReview = reviews.some(r => r.user?.login === assignee);
const eligible =
ageMin >= MAX_MIN &&
!hasIssueComment &&
!hasReviewComment &&
!hasReview &&
pr.state === "open";
core.info(` - @${assignee}: assigned ${ageMin.toFixed(1)} min ago; commented=${hasIssueComment || hasReviewComment}; reviewed=${hasReview}; open=${pr.state==='open'} => ${eligible ? 'ELIGIBLE' : 'skip'}`);
if (!eligible) continue;
if (DRY_RUN) {
core.notice(`Would unassign @${assignee} from PR #${number}`);
} else {
try {
await github.rest.issues.removeAssignees({
owner, repo, issue_number: number, assignees: [assignee]
});
totalUnassigned += 1;
// Optional: leave a gentle heads-up comment
await github.rest.issues.createComment({
owner, repo, issue_number: number,
body: `👋 Unassigning @${assignee} due to inactivity (> ${MAX_MIN} min without comments/reviews). This PR remains open for other reviewers.`
});
core.info(` Unassigned @${assignee} from #${number}`);
} catch (err) {
core.warning(` Failed to unassign @${assignee} from #${number}: ${err.message}`);
}
}
}
}
core.summary
.addHeading('Auto-unassign report')
.addRaw(`Threshold: ${MAX_MIN} minutes\n\n`)
.addRaw(`Total unassignments: ${totalUnassigned}\n`)
.write();
result-encoding: string