Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions .github/workflows/pr-auto-unassign-stale.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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
Loading