| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 |
- name: close-stale-prs
- on:
- workflow_dispatch:
- inputs:
- dryRun:
- description: "Log actions without closing PRs"
- type: boolean
- default: false
- schedule:
- - cron: "0 6 * * *"
- permissions:
- contents: read
- issues: write
- pull-requests: write
- jobs:
- close-stale-prs:
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - name: Close inactive PRs
- uses: actions/github-script@v8
- with:
- 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"
- 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) {
- pullRequests(first: 100, states: OPEN, after: $cursor) {
- pageInfo {
- hasNextPage
- endCursor
- }
- nodes {
- number
- title
- author {
- login
- }
- createdAt
- commits(last: 1) {
- nodes {
- commit {
- committedDate
- }
- }
- }
- comments(last: 1) {
- nodes {
- createdAt
- }
- }
- reviews(last: 1) {
- nodes {
- createdAt
- }
- }
- }
- }
- }
- }
- `
- const allPrs = []
- let cursor = null
- let hasNextPage = true
- let pageCount = 0
- while (hasNextPage) {
- 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`)
- const stalePrs = allPrs.filter((pr) => {
- const dates = [
- new Date(pr.createdAt),
- pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
- pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
- pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
- ].filter((d) => d !== null)
- const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
- if (!lastActivity || lastActivity > cutoff) {
- core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
- return false
- }
- core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
- return true
- })
- if (!stalePrs.length) {
- core.info("No stale pull requests found.")
- return
- }
- 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 || 'unknown'}: ${pr.title}`)
- continue
- }
- 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(`=============================`)
|