From ba35ed3d586f81ef9f1f326bef876b63e28db04a Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Sun, 1 Feb 2026 09:59:39 +0200 Subject: [PATCH] ci: add ratelimits handling for close-stale-prs.yml --- .github/workflows/close-stale-prs.yml | 132 ++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 19 deletions(-) diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index e1ff4241c9..e0e571b469 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -18,6 +18,7 @@ permissions: jobs: close-stale-prs: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Close inactive PRs uses: actions/github-script@v8 @@ -25,6 +26,15 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const DAYS_INACTIVE = 60 + const MAX_RETRIES = 3 + + // Adaptive delay: fast for small batches, slower for large to respect + // GitHub's 80 content-generating requests/minute limit + const SMALL_BATCH_THRESHOLD = 10 + const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs) + const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit + + const startTime = Date.now() const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) const { owner, repo } = context.repo const dryRun = context.payload.inputs?.dryRun === "true" @@ -32,6 +42,42 @@ jobs: core.info(`Dry run mode: ${dryRun}`) core.info(`Cutoff date: ${cutoff.toISOString()}`) + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + async function withRetry(fn, description = 'API call') { + let lastError + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const result = await fn() + return result + } catch (error) { + lastError = error + const isRateLimited = error.status === 403 && + (error.message?.includes('rate limit') || error.message?.includes('secondary')) + + if (!isRateLimited) { + throw error + } + + // Parse retry-after header, default to 60 seconds + const retryAfter = error.response?.headers?.['retry-after'] + ? parseInt(error.response.headers['retry-after']) + : 60 + + // Exponential backoff: retryAfter * 2^attempt + const backoffMs = retryAfter * 1000 * Math.pow(2, attempt) + + core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`) + + await sleep(backoffMs) + } + } + core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`) + throw lastError + } + const query = ` query($owner: String!, $repo: String!, $cursor: String) { repository(owner: $owner, name: $repo) { @@ -73,17 +119,27 @@ jobs: const allPrs = [] let cursor = null let hasNextPage = true + let pageCount = 0 while (hasNextPage) { - const result = await github.graphql(query, { - owner, - repo, - cursor, - }) + pageCount++ + core.info(`Fetching page ${pageCount} of open PRs...`) + + const result = await withRetry( + () => github.graphql(query, { owner, repo, cursor }), + `GraphQL page ${pageCount}` + ) allPrs.push(...result.repository.pullRequests.nodes) hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage cursor = result.repository.pullRequests.pageInfo.endCursor + + core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`) + + // Delay between pagination requests (use small batch delay for reads) + if (hasNextPage) { + await sleep(SMALL_BATCH_DELAY_MS) + } } core.info(`Found ${allPrs.length} open pull requests`) @@ -114,28 +170,66 @@ jobs: core.info(`Found ${stalePrs.length} stale pull requests`) + // ============================================ + // Close stale PRs + // ============================================ + const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD + ? LARGE_BATCH_DELAY_MS + : SMALL_BATCH_DELAY_MS + + core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`) + + let closedCount = 0 + let skippedCount = 0 + for (const pr of stalePrs) { const issue_number = pr.number 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.` if (dryRun) { - core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`) + core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) continue } - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body: closeComment, - }) + try { + // Add comment + await withRetry( + () => github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: closeComment, + }), + `Comment on PR #${issue_number}` + ) - await github.rest.pulls.update({ - owner, - repo, - pull_number: issue_number, - state: "closed", - }) + // Close PR + await withRetry( + () => github.rest.pulls.update({ + owner, + repo, + pull_number: issue_number, + state: "closed", + }), + `Close PR #${issue_number}` + ) - core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`) + closedCount++ + core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) + + // Delay before processing next PR + await sleep(requestDelayMs) + } catch (error) { + skippedCount++ + core.error(`Failed to close PR #${issue_number}: ${error.message}`) + } } + + const elapsed = Math.round((Date.now() - startTime) / 1000) + core.info(`\n========== Summary ==========`) + core.info(`Total open PRs found: ${allPrs.length}`) + core.info(`Stale PRs identified: ${stalePrs.length}`) + core.info(`PRs closed: ${closedCount}`) + core.info(`PRs skipped (errors): ${skippedCount}`) + core.info(`Elapsed time: ${elapsed}s`) + core.info(`=============================`)