Skip to content

Commit 5bdc638

Browse files
authored
Create pr-auto-unassign-stale.yml (#1886)
1 parent 7639951 commit 5bdc638

File tree

1 file changed

+154
-0
lines changed

1 file changed

+154
-0
lines changed
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
name: Auto-unassign stale PR assignees
2+
3+
on:
4+
schedule:
5+
- cron: "*/15 * * * *" # run every 15 minutes
6+
workflow_dispatch:
7+
inputs:
8+
enabled:
9+
description: "Enable this automation"
10+
type: boolean
11+
default: true
12+
max_age_minutes:
13+
description: "Unassign if assigned longer than X minutes"
14+
type: number
15+
default: 60
16+
dry_run:
17+
description: "Preview only; do not change assignees"
18+
type: boolean
19+
default: false
20+
21+
permissions:
22+
pull-requests: write
23+
issues: write
24+
25+
env:
26+
# Defaults (can be overridden via workflow_dispatch inputs)
27+
ENABLED: "true"
28+
MAX_ASSIGN_AGE_MINUTES: "60"
29+
DRY_RUN: "false"
30+
31+
jobs:
32+
sweep:
33+
runs-on: ubuntu-latest
34+
steps:
35+
- name: Resolve inputs into env
36+
run: |
37+
# Prefer manual run inputs when present
38+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
39+
echo "ENABLED=${{ inputs.enabled }}" >> $GITHUB_ENV
40+
echo "MAX_ASSIGN_AGE_MINUTES=${{ inputs.max_age_minutes }}" >> $GITHUB_ENV
41+
echo "DRY_RUN=${{ inputs.dry_run }}" >> $GITHUB_ENV
42+
fi
43+
echo "Effective config: ENABLED=$ENABLED, MAX_ASSIGN_AGE_MINUTES=$MAX_ASSIGN_AGE_MINUTES, DRY_RUN=$DRY_RUN"
44+
45+
- name: Exit if disabled
46+
if: ${{ env.ENABLED != 'true' && env.ENABLED != 'True' && env.ENABLED != 'TRUE' }}
47+
run: echo "Disabled via ENABLED=$ENABLED. Exiting." && exit 0
48+
49+
- name: Unassign stale assignees
50+
uses: actions/github-script@v7
51+
with:
52+
script: |
53+
const owner = context.repo.owner;
54+
const repo = context.repo.repo;
55+
56+
const MAX_MIN = parseInt(process.env.MAX_ASSIGN_AGE_MINUTES || "60", 10);
57+
const DRY_RUN = ["true","True","TRUE","1","yes"].includes(String(process.env.DRY_RUN));
58+
const now = new Date();
59+
60+
core.info(`Scanning open PRs. Threshold = ${MAX_MIN} minutes. DRY_RUN=${DRY_RUN}`);
61+
62+
// List all open PRs
63+
const prs = await github.paginate(github.rest.pulls.list, {
64+
owner, repo, state: "open", per_page: 100
65+
});
66+
67+
let totalUnassigned = 0;
68+
69+
for (const pr of prs) {
70+
if (!pr.assignees || pr.assignees.length === 0) continue;
71+
72+
const number = pr.number;
73+
core.info(`PR #${number}: "${pr.title}" — assignees: ${pr.assignees.map(a => a.login).join(", ")}`);
74+
75+
// Pull reviews (to see if an assignee started a review)
76+
const reviews = await github.paginate(github.rest.pulls.listReviews, {
77+
owner, repo, pull_number: number, per_page: 100
78+
});
79+
80+
// Issue comments (general comments)
81+
const issueComments = await github.paginate(github.rest.issues.listComments, {
82+
owner, repo, issue_number: number, per_page: 100
83+
});
84+
85+
// Review comments (file-level)
86+
const reviewComments = await github.paginate(github.rest.pulls.listReviewComments, {
87+
owner, repo, pull_number: number, per_page: 100
88+
});
89+
90+
// Issue events (to find assignment timestamps)
91+
const issueEvents = await github.paginate(github.rest.issues.listEvents, {
92+
owner, repo, issue_number: number, per_page: 100
93+
});
94+
95+
for (const a of pr.assignees) {
96+
const assignee = a.login;
97+
98+
// Find the most recent "assigned" event for this assignee
99+
const assignedEvents = issueEvents
100+
.filter(e => e.event === "assigned" && e.assignee && e.assignee.login === assignee)
101+
.sort((x, y) => new Date(y.created_at) - new Date(x.created_at));
102+
103+
if (assignedEvents.length === 0) {
104+
core.info(` - @${assignee}: no 'assigned' event found; skipping.`);
105+
continue;
106+
}
107+
108+
const assignedAt = new Date(assignedEvents[0].created_at);
109+
const ageMin = (now - assignedAt) / 60000;
110+
111+
// Has the assignee commented (issue or review comments) or reviewed?
112+
const hasIssueComment = issueComments.some(c => c.user?.login === assignee);
113+
const hasReviewComment = reviewComments.some(c => c.user?.login === assignee);
114+
const hasReview = reviews.some(r => r.user?.login === assignee);
115+
116+
const eligible =
117+
ageMin >= MAX_MIN &&
118+
!hasIssueComment &&
119+
!hasReviewComment &&
120+
!hasReview &&
121+
pr.state === "open";
122+
123+
core.info(` - @${assignee}: assigned ${ageMin.toFixed(1)} min ago; commented=${hasIssueComment || hasReviewComment}; reviewed=${hasReview}; open=${pr.state==='open'} => ${eligible ? 'ELIGIBLE' : 'skip'}`);
124+
125+
if (!eligible) continue;
126+
127+
if (DRY_RUN) {
128+
core.notice(`Would unassign @${assignee} from PR #${number}`);
129+
} else {
130+
try {
131+
await github.rest.issues.removeAssignees({
132+
owner, repo, issue_number: number, assignees: [assignee]
133+
});
134+
totalUnassigned += 1;
135+
// Optional: leave a gentle heads-up comment
136+
await github.rest.issues.createComment({
137+
owner, repo, issue_number: number,
138+
body: `👋 Unassigning @${assignee} due to inactivity (> ${MAX_MIN} min without comments/reviews). This PR remains open for other reviewers.`
139+
});
140+
core.info(` Unassigned @${assignee} from #${number}`);
141+
} catch (err) {
142+
core.warning(` Failed to unassign @${assignee} from #${number}: ${err.message}`);
143+
}
144+
}
145+
}
146+
}
147+
148+
core.summary
149+
.addHeading('Auto-unassign report')
150+
.addRaw(`Threshold: ${MAX_MIN} minutes\n\n`)
151+
.addRaw(`Total unassignments: ${totalUnassigned}\n`)
152+
.write();
153+
154+
result-encoding: string

0 commit comments

Comments
 (0)