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
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
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
2423const 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 ;
2827const LABELS = [ "good-first-issue" , "good-second-issue" ] ;
2928const 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
3146const same = ( a , b ) => a ?. toLowerCase ( ) === b ?. toLowerCase ( ) ;
47+
3248const hasNewcomerLabel = issue =>
3349 issue . labels . some ( label => LABELS . includes ( label . name ) ) ;
50+
3451const 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} ;
0 commit comments