Skip to content

Commit c53b39c

Browse files
rekram1-noderustybret
authored andcommitted
ci: skip previously cleaned PRs (anomalyco#27670)
1 parent ce2fc0a commit c53b39c

1 file changed

Lines changed: 52 additions & 6 deletions

File tree

script/github/close-prs.ts

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const defaultThreshold = 2
88
const defaultSleepMs = 20_000
99
const defaultPrintLimit = 50
1010
const positiveReactions = new Set(["THUMBS_UP", "HEART", "HOORAY", "ROCKET"])
11+
const cleanupLabel = "automated-pr-cleanup"
1112

1213
const { 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

9298
type 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+
255295
async 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+
292338
function requireRepo(value: string | undefined) {
293339
if (!value) throw new Error("repo is required")
294340
const [owner, name] = value.split("/")

0 commit comments

Comments
 (0)