@@ -8,6 +8,7 @@ const defaultThreshold = 2
88const defaultSleepMs = 20_000
99const defaultPrintLimit = 50
1010const positiveReactions = new Set ( [ "THUMBS_UP" , "HEART" , "HOORAY" , "ROCKET" ] )
11+ const cleanupLabel = "automated-pr-cleanup"
1112
1213const { values } = parseArgs ( {
1314 args : Bun . argv . slice ( 2 ) ,
@@ -87,6 +88,11 @@ type PullRequest = {
8788 totalCount : number
8889 }
8990 } >
91+ labels : {
92+ nodes : Array < {
93+ name : string
94+ } >
95+ }
9096}
9197
9298type GraphqlResponse = {
@@ -140,16 +146,18 @@ async function main() {
140146
141147 const prs = await fetchOpenPullRequests ( )
142148 const recentCount = prs . filter ( ( pr ) => new Date ( pr . createdAt ) >= cutoff ) . length
143- const candidates = prs
149+ const matching = prs
144150 . map ( ( pr ) => ( { ...pr , positiveReactions : positiveReactionCount ( pr ) } ) )
145151 . filter ( ( pr ) => new Date ( pr . createdAt ) < cutoff && pr . positiveReactions < threshold )
152+ const candidates = matching . filter ( ( pr ) => ! hasPriorCleanup ( pr ) )
146153 const selected = maxClose === undefined ? candidates : candidates . slice ( 0 , maxClose )
147154
148155 console . log ( `Fetched ${ prs . length } open PRs` )
149- console . log ( `Matching cleanup criteria: ${ candidates . length } ` )
156+ console . log ( `Matching cleanup criteria: ${ matching . length } ` )
157+ console . log ( `Skipped previously cleaned PRs: ${ matching . length - candidates . length } ` )
150158 console . log ( `Recent PRs untouched: ${ recentCount } ` )
151159 console . log (
152- `Older PRs with at least ${ threshold } positive reactions untouched: ${ prs . length - candidates . length - recentCount } ` ,
160+ `Older PRs with at least ${ threshold } positive reactions untouched: ${ prs . length - matching . length - recentCount } ` ,
153161 )
154162
155163 if ( selected . length === 0 ) return
@@ -164,6 +172,8 @@ async function main() {
164172 return
165173 }
166174
175+ await ensureCleanupLabel ( )
176+
167177 console . log ( `\nCommenting and closing ${ selected . length } PRs...` )
168178 for ( const pr of selected ) {
169179 await closePullRequest ( pr )
@@ -201,6 +211,11 @@ async function fetchOpenPullRequests() {
201211 totalCount
202212 }
203213 }
214+ labels(first: 100) {
215+ nodes {
216+ name
217+ }
218+ }
204219 }
205220 }
206221 }
@@ -249,9 +264,34 @@ async function closePullRequest(pr: CleanupCandidate) {
249264 method : "PATCH" ,
250265 body : JSON . stringify ( { state : "closed" } ) ,
251266 } )
267+ await githubRequest ( `/repos/${ repo . owner } /${ repo . name } /issues/${ pr . number } /labels` , {
268+ method : "POST" ,
269+ body : JSON . stringify ( { labels : [ cleanupLabel ] } ) ,
270+ } )
252271 console . log ( `Closed #${ pr . number } positive=${ pr . positiveReactions } ${ pr . url } ` )
253272}
254273
274+ async function ensureCleanupLabel ( ) {
275+ const response = await fetch (
276+ `https://api.github.com/repos/${ repo . owner } /${ repo . name } /labels/${ encodeURIComponent ( cleanupLabel ) } ` ,
277+ {
278+ headers,
279+ } ,
280+ )
281+ if ( response . ok ) return
282+ if ( response . status !== 404 )
283+ throw new Error ( `Failed to check cleanup label: ${ response . status } ${ response . statusText } ` )
284+
285+ await githubRequest ( `/repos/${ repo . owner } /${ repo . name } /labels` , {
286+ method : "POST" ,
287+ body : JSON . stringify ( {
288+ name : cleanupLabel ,
289+ color : "ededed" ,
290+ description : "PR was closed by automated cleanup" ,
291+ } ) ,
292+ } )
293+ }
294+
255295async function githubRequest ( path : string , init : RequestInit , attempt = 0 ) : Promise < Response > {
256296 const response = await fetch ( path . startsWith ( "https://" ) ? path : `https://api.github.com${ path } ` , {
257297 ...init ,
@@ -272,10 +312,12 @@ async function githubRequest(path: string, init: RequestInit, attempt = 0): Prom
272312 ? Math . max ( 0 , Number ( reset ) * 1000 - Date . now ( ) ) + 1_000
273313 : body . toLowerCase ( ) . includes ( "secondary rate limit" )
274314 ? 300_000
275- : 0
315+ : response . status >= 500
316+ ? Math . min ( 300_000 , 10_000 * 2 ** attempt )
317+ : 0
276318
277- if ( ( response . status === 403 || response . status === 429 ) && retryMs > 0 && attempt < 10 ) {
278- console . warn ( `GitHub rate limit hit ; sleeping ${ Math . ceil ( retryMs / 1000 ) } s before retry ${ attempt + 1 } ` )
319+ if ( ( response . status === 403 || response . status === 429 || response . status >= 500 ) && retryMs > 0 && attempt < 10 ) {
320+ console . warn ( `GitHub request failed ; sleeping ${ Math . ceil ( retryMs / 1000 ) } s before retry ${ attempt + 1 } ` )
279321 await sleep ( retryMs )
280322 return githubRequest ( path , init , attempt + 1 )
281323 }
@@ -289,6 +331,10 @@ function positiveReactionCount(pr: PullRequest) {
289331 . reduce ( ( total , group ) => total + group . users . totalCount , 0 )
290332}
291333
334+ function hasPriorCleanup ( pr : PullRequest ) {
335+ return pr . labels . nodes . some ( ( label ) => label . name === cleanupLabel )
336+ }
337+
292338function requireRepo ( value : string | undefined ) {
293339 if ( ! value ) throw new Error ( "repo is required" )
294340 const [ owner , name ] = value . split ( "/" )
0 commit comments