Skip to content

Commit 1bd5dc5

Browse files
authored
ci: add ratelimits handling for close-stale-prs.yml (anomalyco#11578)
1 parent 06d63ca commit 1bd5dc5

File tree

1 file changed

+115
-21
lines changed

1 file changed

+115
-21
lines changed

.github/workflows/close-stale-prs.yml

Lines changed: 115 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,66 @@ permissions:
1818
jobs:
1919
close-stale-prs:
2020
runs-on: ubuntu-latest
21+
timeout-minutes: 15
2122
steps:
2223
- name: Close inactive PRs
2324
uses: actions/github-script@v8
2425
with:
2526
github-token: ${{ secrets.GITHUB_TOKEN }}
2627
script: |
2728
const DAYS_INACTIVE = 60
29+
const MAX_RETRIES = 3
30+
31+
// Adaptive delay: fast for small batches, slower for large to respect
32+
// GitHub's 80 content-generating requests/minute limit
33+
const SMALL_BATCH_THRESHOLD = 10
34+
const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
35+
const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
36+
37+
const startTime = Date.now()
2838
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
2939
const { owner, repo } = context.repo
3040
const dryRun = context.payload.inputs?.dryRun === "true"
3141
3242
core.info(`Dry run mode: ${dryRun}`)
3343
core.info(`Cutoff date: ${cutoff.toISOString()}`)
3444
45+
function sleep(ms) {
46+
return new Promise(resolve => setTimeout(resolve, ms))
47+
}
48+
49+
async function withRetry(fn, description = 'API call') {
50+
let lastError
51+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
52+
try {
53+
const result = await fn()
54+
return result
55+
} catch (error) {
56+
lastError = error
57+
const isRateLimited = error.status === 403 &&
58+
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
59+
60+
if (!isRateLimited) {
61+
throw error
62+
}
63+
64+
// Parse retry-after header, default to 60 seconds
65+
const retryAfter = error.response?.headers?.['retry-after']
66+
? parseInt(error.response.headers['retry-after'])
67+
: 60
68+
69+
// Exponential backoff: retryAfter * 2^attempt
70+
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
71+
72+
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
73+
74+
await sleep(backoffMs)
75+
}
76+
}
77+
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
78+
throw lastError
79+
}
80+
3581
const query = `
3682
query($owner: String!, $repo: String!, $cursor: String) {
3783
repository(owner: $owner, name: $repo) {
@@ -73,17 +119,27 @@ jobs:
73119
const allPrs = []
74120
let cursor = null
75121
let hasNextPage = true
122+
let pageCount = 0
76123
77124
while (hasNextPage) {
78-
const result = await github.graphql(query, {
79-
owner,
80-
repo,
81-
cursor,
82-
})
125+
pageCount++
126+
core.info(`Fetching page ${pageCount} of open PRs...`)
127+
128+
const result = await withRetry(
129+
() => github.graphql(query, { owner, repo, cursor }),
130+
`GraphQL page ${pageCount}`
131+
)
83132
84133
allPrs.push(...result.repository.pullRequests.nodes)
85134
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
86135
cursor = result.repository.pullRequests.pageInfo.endCursor
136+
137+
core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
138+
139+
// Delay between pagination requests (use small batch delay for reads)
140+
if (hasNextPage) {
141+
await sleep(SMALL_BATCH_DELAY_MS)
142+
}
87143
}
88144
89145
core.info(`Found ${allPrs.length} open pull requests`)
@@ -114,28 +170,66 @@ jobs:
114170
115171
core.info(`Found ${stalePrs.length} stale pull requests`)
116172
173+
// ============================================
174+
// Close stale PRs
175+
// ============================================
176+
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
177+
? LARGE_BATCH_DELAY_MS
178+
: SMALL_BATCH_DELAY_MS
179+
180+
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
181+
182+
let closedCount = 0
183+
let skippedCount = 0
184+
117185
for (const pr of stalePrs) {
118186
const issue_number = pr.number
119187
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
120188
121189
if (dryRun) {
122-
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
190+
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
123191
continue
124192
}
125193
126-
await github.rest.issues.createComment({
127-
owner,
128-
repo,
129-
issue_number,
130-
body: closeComment,
131-
})
132-
133-
await github.rest.pulls.update({
134-
owner,
135-
repo,
136-
pull_number: issue_number,
137-
state: "closed",
138-
})
139-
140-
core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
194+
try {
195+
// Add comment
196+
await withRetry(
197+
() => github.rest.issues.createComment({
198+
owner,
199+
repo,
200+
issue_number,
201+
body: closeComment,
202+
}),
203+
`Comment on PR #${issue_number}`
204+
)
205+
206+
// Close PR
207+
await withRetry(
208+
() => github.rest.pulls.update({
209+
owner,
210+
repo,
211+
pull_number: issue_number,
212+
state: "closed",
213+
}),
214+
`Close PR #${issue_number}`
215+
)
216+
217+
closedCount++
218+
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
219+
220+
// Delay before processing next PR
221+
await sleep(requestDelayMs)
222+
} catch (error) {
223+
skippedCount++
224+
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
225+
}
141226
}
227+
228+
const elapsed = Math.round((Date.now() - startTime) / 1000)
229+
core.info(`\n========== Summary ==========`)
230+
core.info(`Total open PRs found: ${allPrs.length}`)
231+
core.info(`Stale PRs identified: ${stalePrs.length}`)
232+
core.info(`PRs closed: ${closedCount}`)
233+
core.info(`PRs skipped (errors): ${skippedCount}`)
234+
core.info(`Elapsed time: ${elapsed}s`)
235+
core.info(`=============================`)

0 commit comments

Comments
 (0)