-
Notifications
You must be signed in to change notification settings - Fork 919
154 lines (127 loc) · 6.1 KB
/
pr-auto-unassign-stale.yml
File metadata and controls
154 lines (127 loc) · 6.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
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