close-stale-prs.yml 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. name: close-stale-prs
  2. on:
  3. workflow_dispatch:
  4. inputs:
  5. dryRun:
  6. description: "Log actions without closing PRs"
  7. type: boolean
  8. default: false
  9. permissions:
  10. contents: read
  11. issues: write
  12. pull-requests: write
  13. jobs:
  14. close-stale-prs:
  15. runs-on: ubuntu-latest
  16. timeout-minutes: 15
  17. steps:
  18. - name: Close inactive PRs
  19. uses: actions/github-script@v8
  20. with:
  21. github-token: ${{ secrets.GITHUB_TOKEN }}
  22. script: |
  23. const DAYS_INACTIVE = 60
  24. const MAX_RETRIES = 3
  25. // Adaptive delay: fast for small batches, slower for large to respect
  26. // GitHub's 80 content-generating requests/minute limit
  27. const SMALL_BATCH_THRESHOLD = 10
  28. const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
  29. const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
  30. const startTime = Date.now()
  31. const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
  32. const { owner, repo } = context.repo
  33. const dryRun = context.payload.inputs?.dryRun === "true"
  34. core.info(`Dry run mode: ${dryRun}`)
  35. core.info(`Cutoff date: ${cutoff.toISOString()}`)
  36. function sleep(ms) {
  37. return new Promise(resolve => setTimeout(resolve, ms))
  38. }
  39. async function withRetry(fn, description = 'API call') {
  40. let lastError
  41. for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
  42. try {
  43. const result = await fn()
  44. return result
  45. } catch (error) {
  46. lastError = error
  47. const isRateLimited = error.status === 403 &&
  48. (error.message?.includes('rate limit') || error.message?.includes('secondary'))
  49. if (!isRateLimited) {
  50. throw error
  51. }
  52. // Parse retry-after header, default to 60 seconds
  53. const retryAfter = error.response?.headers?.['retry-after']
  54. ? parseInt(error.response.headers['retry-after'])
  55. : 60
  56. // Exponential backoff: retryAfter * 2^attempt
  57. const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
  58. core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
  59. await sleep(backoffMs)
  60. }
  61. }
  62. core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
  63. throw lastError
  64. }
  65. const query = `
  66. query($owner: String!, $repo: String!, $cursor: String) {
  67. repository(owner: $owner, name: $repo) {
  68. pullRequests(first: 100, states: OPEN, after: $cursor) {
  69. pageInfo {
  70. hasNextPage
  71. endCursor
  72. }
  73. nodes {
  74. number
  75. title
  76. author {
  77. login
  78. }
  79. createdAt
  80. commits(last: 1) {
  81. nodes {
  82. commit {
  83. committedDate
  84. }
  85. }
  86. }
  87. comments(last: 1) {
  88. nodes {
  89. createdAt
  90. }
  91. }
  92. reviews(last: 1) {
  93. nodes {
  94. createdAt
  95. }
  96. }
  97. }
  98. }
  99. }
  100. }
  101. `
  102. const allPrs = []
  103. let cursor = null
  104. let hasNextPage = true
  105. let pageCount = 0
  106. while (hasNextPage) {
  107. pageCount++
  108. core.info(`Fetching page ${pageCount} of open PRs...`)
  109. const result = await withRetry(
  110. () => github.graphql(query, { owner, repo, cursor }),
  111. `GraphQL page ${pageCount}`
  112. )
  113. allPrs.push(...result.repository.pullRequests.nodes)
  114. hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
  115. cursor = result.repository.pullRequests.pageInfo.endCursor
  116. core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
  117. // Delay between pagination requests (use small batch delay for reads)
  118. if (hasNextPage) {
  119. await sleep(SMALL_BATCH_DELAY_MS)
  120. }
  121. }
  122. core.info(`Found ${allPrs.length} open pull requests`)
  123. const stalePrs = allPrs.filter((pr) => {
  124. const dates = [
  125. new Date(pr.createdAt),
  126. pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
  127. pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
  128. pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
  129. ].filter((d) => d !== null)
  130. const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
  131. if (!lastActivity || lastActivity > cutoff) {
  132. core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
  133. return false
  134. }
  135. core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
  136. return true
  137. })
  138. if (!stalePrs.length) {
  139. core.info("No stale pull requests found.")
  140. return
  141. }
  142. core.info(`Found ${stalePrs.length} stale pull requests`)
  143. // ============================================
  144. // Close stale PRs
  145. // ============================================
  146. const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
  147. ? LARGE_BATCH_DELAY_MS
  148. : SMALL_BATCH_DELAY_MS
  149. core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
  150. let closedCount = 0
  151. let skippedCount = 0
  152. for (const pr of stalePrs) {
  153. const issue_number = pr.number
  154. 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.`
  155. if (dryRun) {
  156. core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
  157. continue
  158. }
  159. try {
  160. // Add comment
  161. await withRetry(
  162. () => github.rest.issues.createComment({
  163. owner,
  164. repo,
  165. issue_number,
  166. body: closeComment,
  167. }),
  168. `Comment on PR #${issue_number}`
  169. )
  170. // Close PR
  171. await withRetry(
  172. () => github.rest.pulls.update({
  173. owner,
  174. repo,
  175. pull_number: issue_number,
  176. state: "closed",
  177. }),
  178. `Close PR #${issue_number}`
  179. )
  180. closedCount++
  181. core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
  182. // Delay before processing next PR
  183. await sleep(requestDelayMs)
  184. } catch (error) {
  185. skippedCount++
  186. core.error(`Failed to close PR #${issue_number}: ${error.message}`)
  187. }
  188. }
  189. const elapsed = Math.round((Date.now() - startTime) / 1000)
  190. core.info(`\n========== Summary ==========`)
  191. core.info(`Total open PRs found: ${allPrs.length}`)
  192. core.info(`Stale PRs identified: ${stalePrs.length}`)
  193. core.info(`PRs closed: ${closedCount}`)
  194. core.info(`PRs skipped (errors): ${skippedCount}`)
  195. core.info(`Elapsed time: ${elapsed}s`)
  196. core.info(`=============================`)