Skip to content

Commit dbeae60

Browse files
committed
Auto-unassign feature in newcomer issues
1 parent f4dfc7f commit dbeae60

2 files changed

Lines changed: 148 additions & 71 deletions

File tree

‎.github/workflows/unassign_bot.js‎

Lines changed: 146 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
// Licensed to the Apache Software Foundation (ASF) under one
32
// or more contributor license agreements. See the NOTICE file
43
// distributed with this work for additional information
@@ -8,7 +7,6 @@
87
// with the License. You may obtain a copy of the License at
98
//
109
// http://www.apache.org/licenses/LICENSE-2.0
11-
//
1210
// Unless required by applicable law or agreed to in writing,
1311
// software distributed under the License is distributed on an
1412
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@@ -17,20 +15,39 @@
1715
// under the License.
1816

1917
// Releases abandoned assignments on newcomer-friendly issues:
20-
// - a linked PR is closed without merging -> unassign immediately
21-
// - no linked PR and no assignee comment for WARN_AFTER_DAYS -> warn,
18+
// - a linked PR is closed without merging and no other open linked PR
19+
// exists from the same author -> unassign immediately
20+
// - no qualifying activity for WARN_AFTER_DAYS -> warn,
2221
// then unassign GRACE_DAYS later if there is still no activity.
2322

2423
const helpers = require("./dev_pr/helpers.js");
2524

26-
const WARN_AFTER_DAYS = 5 / (24 * 60); // 5 minutes
27-
const GRACE_DAYS = 1 / (24 * 60); // 1 minute
25+
const WARN_AFTER_DAYS = 5;
26+
const GRACE_DAYS = 3;
2827
const LABELS = ["good-first-issue", "good-second-issue"];
2928
const DAY = 24 * 60 * 60 * 1000;
29+
const TIMER_PAUSE_LABELS = [
30+
"awaiting review",
31+
"awaiting committer review",
32+
"awaiting change review",
33+
"awaiting merge",
34+
];
35+
36+
// GitHub author associations used to identify maintainers and collaborators.
37+
const isMaintainer = association =>
38+
["MEMBER", "OWNER", "COLLABORATOR"].includes(association);
39+
40+
// A PR awaiting review or merge is not considered inactive.
41+
const hasTimerPauseLabel = pr =>
42+
pr.labels.some(label =>
43+
TIMER_PAUSE_LABELS.includes(label.name.toLowerCase())
44+
);
3045

3146
const same = (a, b) => a?.toLowerCase() === b?.toLowerCase();
47+
3248
const hasNewcomerLabel = issue =>
3349
issue.labels.some(label => LABELS.includes(label.name));
50+
3451
const warningMarker = login =>
3552
`<!-- unassign-bot-warning:${login.toLowerCase()} -->`;
3653

@@ -82,12 +99,23 @@ module.exports = async ({ github, context }) => {
8299
return;
83100
}
84101

85-
const issue = await github.rest.issues
86-
.get({ owner, repo, issue_number: number })
87-
.then(r => r.data, () => null); // the title may name a missing issue
102+
let issue;
103+
try {
104+
issue = (
105+
await github.rest.issues.get({
106+
owner,
107+
repo,
108+
issue_number: number,
109+
})
110+
).data;
111+
} catch (error) {
112+
if (error.status !== 404) {
113+
throw error;
114+
}
88115

89-
if (!issue) {
90-
console.log(`Skipping PR #${pr.number}: referenced issue #${number} was not found`);
116+
console.log(
117+
`Skipping PR #${pr.number}: referenced issue #${number} was not found`
118+
);
91119
return;
92120
}
93121

@@ -122,7 +150,7 @@ module.exports = async ({ github, context }) => {
122150
return;
123151
}
124152

125-
// Scheduled / manual: warn, then release assignments with no author PR.
153+
// Scheduled / manual: warn, then release inactive assignments.
126154
const byNumber = new Map();
127155

128156
for (const label of LABELS) {
@@ -138,26 +166,32 @@ module.exports = async ({ github, context }) => {
138166
}
139167
}
140168

169+
// Fetch open PRs once and reuse them for all assigned issues.
141170
const openPrs = await paginate(github.rest.pulls.list, { state: "open" });
142171

172+
let failures = 0;
173+
143174
for (const issue of byNumber.values()) {
144175
for (const { login } of issue.assignees) {
145176
try {
146-
// An open linked PR protects only its own author.
147-
const hasOpenPr = openPrs.some(
177+
// Only consider linked PRs opened by this assignee.
178+
const linkedPrs = openPrs.filter(
148179
pr =>
149180
linkedIssue(pr) === issue.number &&
150181
same(pr.user.login, login)
151182
);
152183

153-
if (hasOpenPr) {
184+
// Pause the timer while the PR is awaiting review.
185+
if (linkedPrs.some(hasTimerPauseLabel)) {
154186
continue;
155187
}
156188

157189
const events = await paginate(github.rest.issues.listEvents, {
158190
issue_number: issue.number,
159191
});
160192

193+
// Use the most recent assignment event to avoid counting activity from
194+
// before the contributor was assigned.
161195
const assignedAt = Math.max(
162196
0,
163197
...events
@@ -170,39 +204,120 @@ module.exports = async ({ github, context }) => {
170204
);
171205

172206
if (!assignedAt) {
173-
continue; // unknown assignment time: keep the assignment
207+
continue; // Unknown assignment time: keep the assignment.
174208
}
175209

176210
const comments = await paginate(github.rest.issues.listComments, {
177211
issue_number: issue.number,
178212
since: new Date(assignedAt).toISOString(),
179213
});
180214

215+
// Activity on a linked PR counts when it comes from the assignee or
216+
// someone with maintainer/collaborator permissions.
217+
const prActivity = [];
218+
219+
for (const pr of linkedPrs) {
220+
const [prComments, reviewComments, reviews, labelEvents, commits] =
221+
await Promise.all([
222+
paginate(github.rest.issues.listComments, {
223+
issue_number: pr.number,
224+
since: new Date(assignedAt).toISOString(),
225+
}),
226+
paginate(github.rest.pulls.listReviewComments, {
227+
pull_number: pr.number,
228+
since: new Date(assignedAt).toISOString(),
229+
}),
230+
paginate(github.rest.pulls.listReviews, {
231+
pull_number: pr.number,
232+
}),
233+
paginate(github.rest.issues.listEvents, {
234+
issue_number: pr.number,
235+
}),
236+
paginate(github.rest.pulls.listCommits, {
237+
pull_number: pr.number,
238+
}),
239+
]);
240+
241+
prActivity.push(
242+
...prComments
243+
.filter(
244+
c =>
245+
Date.parse(c.created_at) >= assignedAt &&
246+
(same(c.user?.login, login) ||
247+
isMaintainer(c.author_association))
248+
)
249+
.map(c => Date.parse(c.created_at)),
250+
251+
...reviewComments
252+
.filter(
253+
c =>
254+
Date.parse(c.created_at) >= assignedAt &&
255+
(same(c.user?.login, login) ||
256+
isMaintainer(c.author_association))
257+
)
258+
.map(c => Date.parse(c.created_at)),
259+
260+
...reviews
261+
.filter(
262+
r =>
263+
r.submitted_at &&
264+
Date.parse(r.submitted_at) >= assignedAt &&
265+
(same(r.user?.login, login) ||
266+
isMaintainer(r.author_association))
267+
)
268+
.map(r => Date.parse(r.submitted_at)),
269+
270+
// Removing a timer-pausing label starts a fresh countdown.
271+
...labelEvents
272+
.filter(
273+
event =>
274+
event.event === "unlabeled" &&
275+
TIMER_PAUSE_LABELS.includes(
276+
event.label?.name?.toLowerCase()
277+
) &&
278+
Date.parse(event.created_at) >= assignedAt
279+
)
280+
.map(event => Date.parse(event.created_at)),
281+
282+
...commits
283+
.filter(
284+
commit =>
285+
Date.parse(commit.commit.committer.date) >= assignedAt &&
286+
same(commit.committer?.login, login)
287+
)
288+
.map(commit => Date.parse(commit.commit.committer.date))
289+
);
290+
}
291+
292+
// The latest qualifying activity determines when the inactivity period starts.
181293
const lastActivity = Math.max(
182294
assignedAt,
183295
...comments
184-
.filter(comment => same(comment.user?.login, login))
185-
.map(comment => Date.parse(comment.created_at))
296+
.filter(
297+
c =>
298+
Date.parse(c.created_at) >= assignedAt &&
299+
same(c.user?.login, login)
300+
)
301+
.map(c => Date.parse(c.created_at)),
302+
...prActivity
186303
);
187304

188305
if (Date.now() - lastActivity < WARN_AFTER_DAYS * DAY) {
189306
continue;
190307
}
191308

192309
const warning = comments.find(
193-
comment =>
194-
comment.body.includes(warningMarker(login)) &&
195-
Date.parse(comment.created_at) >= lastActivity
310+
c =>
311+
c.body.includes(warningMarker(login)) &&
312+
Date.parse(c.created_at) >= lastActivity
196313
);
197314

198315
if (!warning) {
199-
console.log(`Warning ${login} on #${issue.number}`);
200-
201316
await comment(
202317
issue.number,
203-
`${warningMarker(login)}\n@${login}, there has been no linked pull request ` +
204-
`or comment from you on this issue for ${WARN_AFTER_DAYS} days. It will be ` +
205-
`unassigned in ${GRACE_DAYS} days unless you comment here or open a PR.`
318+
`${warningMarker(login)}\n@${login}, there has been no qualifying activity ` +
319+
`on this issue or its linked PR for ${WARN_AFTER_DAYS} days. It will be ` +
320+
`unassigned in ${GRACE_DAYS} days unless there is further activity.`
206321
);
207322
} else if (
208323
Date.now() - Date.parse(warning.created_at) >= GRACE_DAYS * DAY
@@ -214,8 +329,12 @@ module.exports = async ({ github, context }) => {
214329
);
215330
}
216331
} catch (error) {
217-
console.log(`Failed on #${issue.number} (${login}): ${error.message}`);
332+
failures++;
333+
console.error(`Failed on #${issue.number} (${login})`, error);
218334
}
219335
}
220336
}
337+
if (failures > 0) {
338+
throw new Error(`Failed to process ${failures} assignment(s).`);
339+
}
221340
};

‎.github/workflows/unassign_bot.yml‎

Lines changed: 2 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -17,49 +17,6 @@
1717

1818
name: Unassign Inactive Contributors
1919

20-
on:
21-
# Time-based expiry: the only place where inactivity is evaluated.
22-
schedule:
23-
- cron: "0 10 * * *"
24-
# Manual run. Defaults to a dry run that only logs what would happen.
25-
workflow_dispatch:
26-
inputs:
27-
dry_run:
28-
description: "Only log what would be unassigned, change nothing"
29-
type: boolean
30-
default: true
31-
# Immediate handling when a linked PR is closed without merging.
32-
# pull_request_target is required so that the token can write to the
33-
# issue when the PR comes from a fork. No PR code is checked out or run.
34-
pull_request_target:
35-
types:
36-
- closed
37-
38-
permissions:
39-
contents: read
40-
issues: write
41-
pull-requests: read
42-
43-
concurrency:
44-
# Licensed to the Apache Software Foundation (ASF) under one
45-
# or more contributor license agreements. See the NOTICE file
46-
# distributed with this work for additional information
47-
# regarding copyright ownership. The ASF licenses this file
48-
# to you under the Apache License, Version 2.0 (the
49-
# "License"); you may not use this file except in compliance
50-
# with the License. You may obtain a copy of the License at
51-
#
52-
# http://www.apache.org/licenses/LICENSE-2.0
53-
#
54-
# Unless required by applicable law or agreed to in writing,
55-
# software distributed under the License is distributed on an
56-
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
57-
# KIND, either express or implied. See the License for the
58-
# specific language governing permissions and limitations
59-
# under the License.
60-
61-
name: Unassign Inactive Contributors
62-
6320
on:
6421
schedule:
6522
- cron: "0 10 * * *"
@@ -75,6 +32,7 @@ permissions:
7532

7633
concurrency:
7734
group: unassign-inactive-contributors
35+
queue: max
7836
cancel-in-progress: false
7937

8038
jobs:
@@ -94,4 +52,4 @@ jobs:
9452
with:
9553
script: |
9654
const unassignBot = require("./.github/workflows/unassign_bot.js");
97-
await unassignBot({ github, context, core });
55+
await unassignBot({ github, context});

0 commit comments

Comments
 (0)