@@ -8,6 +8,7 @@ const defaultThreshold = 2 |
8 | 8 | const defaultSleepMs = 20_000 |
9 | 9 | const defaultPrintLimit = 50 |
10 | 10 | const positiveReactions = new Set(["THUMBS_UP", "HEART", "HOORAY", "ROCKET"]) |
| 11 | +const cleanupLabel = "automated-pr-cleanup" |
11 | 12 |
|
12 | 13 | const { values } = parseArgs({ |
13 | 14 | args: Bun.argv.slice(2), |
@@ -87,6 +88,11 @@ type PullRequest = { |
87 | 88 | totalCount: number |
88 | 89 | } |
89 | 90 | }> |
| 91 | + labels: { |
| 92 | + nodes: Array<{ |
| 93 | + name: string |
| 94 | + }> |
| 95 | + } |
90 | 96 | } |
91 | 97 |
|
92 | 98 | type GraphqlResponse = { |
@@ -140,16 +146,18 @@ async function main() { |
140 | 146 |
|
141 | 147 | const prs = await fetchOpenPullRequests() |
142 | 148 | const recentCount = prs.filter((pr) => new Date(pr.createdAt) >= cutoff).length |
143 | | - const candidates = prs |
| 149 | + const matching = prs |
144 | 150 | .map((pr) => ({ ...pr, positiveReactions: positiveReactionCount(pr) })) |
145 | 151 | .filter((pr) => new Date(pr.createdAt) < cutoff && pr.positiveReactions < threshold) |
| 152 | + const candidates = matching.filter((pr) => !hasPriorCleanup(pr)) |
146 | 153 | const selected = maxClose === undefined ? candidates : candidates.slice(0, maxClose) |
147 | 154 |
|
148 | 155 | 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}`) |
150 | 158 | console.log(`Recent PRs untouched: ${recentCount}`) |
151 | 159 | 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}`, |
153 | 161 | ) |
154 | 162 |
|
155 | 163 | if (selected.length === 0) return |
@@ -164,6 +172,8 @@ async function main() { |
164 | 172 | return |
165 | 173 | } |
166 | 174 |
|
| 175 | + await ensureCleanupLabel() |
| 176 | + |
167 | 177 | console.log(`\nCommenting and closing ${selected.length} PRs...`) |
168 | 178 | for (const pr of selected) { |
169 | 179 | await closePullRequest(pr) |
@@ -201,6 +211,11 @@ async function fetchOpenPullRequests() { |
201 | 211 | totalCount |
202 | 212 | } |
203 | 213 | } |
| 214 | + labels(first: 100) { |
| 215 | + nodes { |
| 216 | + name |
| 217 | + } |
| 218 | + } |
204 | 219 | } |
205 | 220 | } |
206 | 221 | } |
@@ -249,9 +264,34 @@ async function closePullRequest(pr: CleanupCandidate) { |
249 | 264 | method: "PATCH", |
250 | 265 | body: JSON.stringify({ state: "closed" }), |
251 | 266 | }) |
| 267 | + await githubRequest(`/repos/${repo.owner}/${repo.name}/issues/${pr.number}/labels`, { |
| 268 | + method: "POST", |
| 269 | + body: JSON.stringify({ labels: [cleanupLabel] }), |
| 270 | + }) |
252 | 271 | console.log(`Closed #${pr.number} positive=${pr.positiveReactions} ${pr.url}`) |
253 | 272 | } |
254 | 273 |
|
| 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 | + |
255 | 295 | async function githubRequest(path: string, init: RequestInit, attempt = 0): Promise<Response> { |
256 | 296 | const response = await fetch(path.startsWith("https://") ? path : `https://api.github.com${path}`, { |
257 | 297 | ...init, |
@@ -272,10 +312,12 @@ async function githubRequest(path: string, init: RequestInit, attempt = 0): Prom |
272 | 312 | ? Math.max(0, Number(reset) * 1000 - Date.now()) + 1_000 |
273 | 313 | : body.toLowerCase().includes("secondary rate limit") |
274 | 314 | ? 300_000 |
275 | | - : 0 |
| 315 | + : response.status >= 500 |
| 316 | + ? Math.min(300_000, 10_000 * 2 ** attempt) |
| 317 | + : 0 |
276 | 318 |
|
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}`) |
279 | 321 | await sleep(retryMs) |
280 | 322 | return githubRequest(path, init, attempt + 1) |
281 | 323 | } |
@@ -289,6 +331,10 @@ function positiveReactionCount(pr: PullRequest) { |
289 | 331 | .reduce((total, group) => total + group.users.totalCount, 0) |
290 | 332 | } |
291 | 333 |
|
| 334 | +function hasPriorCleanup(pr: PullRequest) { |
| 335 | + return pr.labels.nodes.some((label) => label.name === cleanupLabel) |
| 336 | +} |
| 337 | + |
292 | 338 | function requireRepo(value: string | undefined) { |
293 | 339 | if (!value) throw new Error("repo is required") |
294 | 340 | const [owner, name] = value.split("/") |
|