summaryrefslogtreecommitdiffhomepage
path: root/.github
diff options
context:
space:
mode:
authorGoni Zahavy <[email protected]>2026-02-02 18:13:48 +0200
committerGitHub <[email protected]>2026-02-02 10:13:48 -0600
commit1bd5dc5382cfa8b57dc470970bcdfa6a3dbd8dfb (patch)
treee03483cd2f600264723db27094b6f24725d670fd /.github
parent06d63ca54cacfce5af7fdab216ffe7f35d778642 (diff)
downloadopencode-1bd5dc5382cfa8b57dc470970bcdfa6a3dbd8dfb.tar.gz
opencode-1bd5dc5382cfa8b57dc470970bcdfa6a3dbd8dfb.zip
ci: add ratelimits handling for close-stale-prs.yml (#11578)
Diffstat (limited to '.github')
-rw-r--r--.github/workflows/close-stale-prs.yml136
1 files changed, 115 insertions, 21 deletions
diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml
index e1ff4241c..e0e571b46 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,
- })
-
- 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(`=============================`)