Auto-unassign stale PR assignees #750
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 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 |