close-stale-prs.yml 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  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. steps:
  19. - name: Close inactive PRs
  20. uses: actions/github-script@v8
  21. with:
  22. github-token: ${{ secrets.GITHUB_TOKEN }}
  23. script: |
  24. const DAYS_INACTIVE = 60
  25. const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
  26. const { owner, repo } = context.repo
  27. const dryRun = context.payload.inputs?.dryRun === "true"
  28. core.info(`Dry run mode: ${dryRun}`)
  29. core.info(`Cutoff date: ${cutoff.toISOString()}`)
  30. const query = `
  31. query($owner: String!, $repo: String!, $cursor: String) {
  32. repository(owner: $owner, name: $repo) {
  33. pullRequests(first: 100, states: OPEN, after: $cursor) {
  34. pageInfo {
  35. hasNextPage
  36. endCursor
  37. }
  38. nodes {
  39. number
  40. title
  41. author {
  42. login
  43. }
  44. createdAt
  45. commits(last: 1) {
  46. nodes {
  47. commit {
  48. committedDate
  49. }
  50. }
  51. }
  52. comments(last: 1) {
  53. nodes {
  54. createdAt
  55. }
  56. }
  57. reviews(last: 1) {
  58. nodes {
  59. createdAt
  60. }
  61. }
  62. }
  63. }
  64. }
  65. }
  66. `
  67. const allPrs = []
  68. let cursor = null
  69. let hasNextPage = true
  70. while (hasNextPage) {
  71. const result = await github.graphql(query, {
  72. owner,
  73. repo,
  74. cursor,
  75. })
  76. allPrs.push(...result.repository.pullRequests.nodes)
  77. hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
  78. cursor = result.repository.pullRequests.pageInfo.endCursor
  79. }
  80. core.info(`Found ${allPrs.length} open pull requests`)
  81. const stalePrs = allPrs.filter((pr) => {
  82. const dates = [
  83. new Date(pr.createdAt),
  84. pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
  85. pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
  86. pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
  87. ].filter((d) => d !== null)
  88. const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
  89. if (!lastActivity || lastActivity > cutoff) {
  90. core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
  91. return false
  92. }
  93. core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
  94. return true
  95. })
  96. if (!stalePrs.length) {
  97. core.info("No stale pull requests found.")
  98. return
  99. }
  100. core.info(`Found ${stalePrs.length} stale pull requests`)
  101. for (const pr of stalePrs) {
  102. const issue_number = pr.number
  103. 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.`
  104. if (dryRun) {
  105. core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
  106. continue
  107. }
  108. await github.rest.issues.createComment({
  109. owner,
  110. repo,
  111. issue_number,
  112. body: closeComment,
  113. })
  114. await github.rest.pulls.update({
  115. owner,
  116. repo,
  117. pull_number: issue_number,
  118. state: "closed",
  119. })
  120. core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
  121. }