close-stale-prs.yml 8.5 KB

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