close-issues.ts 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. #!/usr/bin/env bun
  2. const repo = "anomalyco/opencode"
  3. const days = 60
  4. const msg = `To stay organized issues are automatically closed after ${days} days of no activity. If the issue is still relevant please open a new one.`
  5. const token = process.env.GITHUB_TOKEN
  6. if (!token) {
  7. console.error("GITHUB_TOKEN environment variable is required")
  8. process.exit(1)
  9. }
  10. const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
  11. const agentLogin = "opencode-agent[bot]"
  12. const teamMembers = new Set(
  13. (await Bun.file(new URL("../../.github/TEAM_MEMBERS", import.meta.url)).text())
  14. .split("\n")
  15. .map((line) => line.trim().toLowerCase())
  16. .filter(Boolean),
  17. )
  18. const teamAssociations = new Set(["OWNER", "MEMBER"])
  19. type Issue = {
  20. number: number
  21. updated_at: string
  22. author_association: string
  23. user: { login: string } | null
  24. }
  25. const headers = {
  26. Authorization: `Bearer ${token}`,
  27. "Content-Type": "application/json",
  28. Accept: "application/vnd.github+json",
  29. "X-GitHub-Api-Version": "2022-11-28",
  30. }
  31. function shouldSkip(i: Issue) {
  32. const login = i.user?.login.toLowerCase()
  33. return login === agentLogin || (login ? teamMembers.has(login) : false) || teamAssociations.has(i.author_association)
  34. }
  35. async function close(num: number) {
  36. const base = `https://api.github.com/repos/${repo}/issues/${num}`
  37. const comment = await fetch(`${base}/comments`, {
  38. method: "POST",
  39. headers,
  40. body: JSON.stringify({ body: msg }),
  41. })
  42. if (!comment.ok) throw new Error(`Failed to comment #${num}: ${comment.status} ${comment.statusText}`)
  43. const patch = await fetch(base, {
  44. method: "PATCH",
  45. headers,
  46. body: JSON.stringify({ state: "closed", state_reason: "not_planned" }),
  47. })
  48. if (!patch.ok) throw new Error(`Failed to close #${num}: ${patch.status} ${patch.statusText}`)
  49. console.log(`Closed https://github.com/${repo}/issues/${num}`)
  50. }
  51. async function main() {
  52. let page = 1
  53. let closed = 0
  54. while (true) {
  55. const res = await fetch(
  56. `https://api.github.com/repos/${repo}/issues?state=open&sort=updated&direction=asc&per_page=100&page=${page}`,
  57. { headers },
  58. )
  59. if (!res.ok) throw new Error(res.statusText)
  60. const all = (await res.json()) as Issue[]
  61. if (all.length === 0) break
  62. console.log(`Fetched page ${page} ${all.length} issues`)
  63. const stale: number[] = []
  64. for (const i of all) {
  65. const updated = new Date(i.updated_at)
  66. if (updated < cutoff) {
  67. if (shouldSkip(i)) {
  68. console.log(`Skipping stale issue #${i.number}; author ${i.user?.login ?? "unknown"} is exempt`)
  69. continue
  70. }
  71. stale.push(i.number)
  72. } else {
  73. console.log(`\nFound fresh issue #${i.number}, stopping`)
  74. if (stale.length > 0) {
  75. for (const num of stale) {
  76. await close(num)
  77. closed++
  78. }
  79. }
  80. console.log(`Closed ${closed} issues total`)
  81. return
  82. }
  83. }
  84. if (stale.length > 0) {
  85. for (const num of stale) {
  86. await close(num)
  87. closed++
  88. }
  89. }
  90. page++
  91. }
  92. console.log(`Closed ${closed} issues total`)
  93. }
  94. main().catch((err) => {
  95. console.error("Error:", err)
  96. process.exit(1)
  97. })