Răsfoiți Sursa

ci: add ratelimits handling for close-stale-prs.yml (#11578)

Goni Zahavy 2 luni în urmă
părinte
comite
1bd5dc5382
1 a modificat fișierele cu 115 adăugiri și 21 ștergeri
  1. 115 21
      .github/workflows/close-stale-prs.yml

+ 115 - 21
.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,
-              })
-
-              await github.rest.pulls.update({
-                owner,
-                repo,
-                pull_number: issue_number,
-                state: "closed",
-              })
-
-              core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
+              try {
+                // Add comment
+                await withRetry(
+                  () => github.rest.issues.createComment({
+                    owner,
+                    repo,
+                    issue_number,
+                    body: closeComment,
+                  }),
+                  `Comment on PR #${issue_number}`
+                )
+
+                // Close PR
+                await withRetry(
+                  () => github.rest.pulls.update({
+                    owner,
+                    repo,
+                    pull_number: issue_number,
+                    state: "closed",
+                  }),
+                  `Close PR #${issue_number}`
+                )
+
+                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(`=============================`)