# kilocode_change - new file
name: kilo-auto-close

on:
  workflow_dispatch:
    inputs:
      dryRun:
        description: "Log actions without closing items"
        type: boolean
        default: true
  schedule:
    - cron: "0 6 * * *"

permissions:
  contents: read
  issues: write
  pull-requests: write

jobs:
  close:
    if: github.repository == 'Kilo-Org/kilocode'
    runs-on: blacksmith-2vcpu-ubuntu-2404
    timeout-minutes: 30
    steps:
      - name: Close inactive PRs and issues
        uses: actions/github-script@v8
        env:
          KILO_AUTO_CLOSE_ENABLED: ${{ vars.KILO_AUTO_CLOSE_ENABLED }}
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const PR_DAYS_INACTIVE = 30
            const ISSUE_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 items)
            const LARGE_BATCH_DELAY_MS = 2000  // 2s for backlog (>10 items) = ~30 ops/min, well under 80 limit

            const startTime = Date.now()
            const prCutoff = new Date(Date.now() - PR_DAYS_INACTIVE * 24 * 60 * 60 * 1000)
            const issueCutoff = new Date(Date.now() - ISSUE_DAYS_INACTIVE * 24 * 60 * 60 * 1000)
            const { owner, repo } = context.repo
            const dryRunInput = context.payload.inputs?.dryRun
            const enabled = process.env.KILO_AUTO_CLOSE_ENABLED === "true"
            const dryRun = dryRunInput === undefined
              ? !enabled
              : dryRunInput !== "false"

            core.info(`Dry run mode: ${dryRun}`)
            core.info(`PR cutoff date: ${prCutoff.toISOString()}`)
            core.info(`Issue cutoff date: ${issueCutoff.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
                  }

                  if (attempt === MAX_RETRIES - 1) {
                    break
                  }

                  // 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
                        }
                      }
                      reviewThreads(last: 100) {
                        nodes {
                          comments(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`)

            function entry(source, date) {
              if (!date) return null

              return { source, date: new Date(date) }
            }

            function latest(items) {
              return items.filter(Boolean).sort((a, b) => b.date.getTime() - a.date.getTime())[0]
            }

            const stalePrs = allPrs.flatMap((pr) => {
              const activity = latest([
                entry("created", pr.createdAt),
                entry("commit", pr.commits.nodes[0]?.commit.committedDate),
                entry("comment", pr.comments.nodes[0]?.createdAt),
                entry("review", pr.reviews.nodes[0]?.createdAt),
                ...pr.reviewThreads.nodes.map((t) => entry("review comment", t.comments.nodes[0]?.createdAt)),
              ])
              const text = activity
                ? `${activity.date.toISOString()} via ${activity.source}`
                : "unknown"

              if (!activity || activity.date > prCutoff) {
                core.info(`PR #${pr.number} is fresh (last activity: ${text})`)
                return []
              }

              core.info(`PR #${pr.number} is STALE (last activity: ${text})`)
              return [{ ...pr, activity }]
            })

            core.info(`Found ${stalePrs.length} stale pull requests`)

            const prDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
              ? LARGE_BATCH_DELAY_MS
              : SMALL_BATCH_DELAY_MS

            core.info(`Using ${prDelayMs}ms delay between PR operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)

            let closedPrCount = 0
            let skippedPrCount = 0

            for (const pr of stalePrs) {
              const issue_number = pr.number
              const closeComment = `To stay organized pull requests are automatically closed after ${PR_DAYS_INACTIVE} days of inactivity. If the pull request is still relevant please reopen it or create a fresh new one.`

              if (dryRun) {
                core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'} (last activity: ${pr.activity.date.toISOString()} via ${pr.activity.source}): ${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}`
                )

                closedPrCount++
                core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'} (last activity: ${pr.activity.date.toISOString()} via ${pr.activity.source}): ${pr.title}`)

                // Delay before processing next PR
                await sleep(prDelayMs)
              } catch (error) {
                skippedPrCount++
                core.error(`Failed to close PR #${issue_number}: ${error.message}`)
              }
            }

            let page = 1
            let stop = false
            const staleIssues = []

            while (!stop) {
              core.info(`Fetching page ${page} of open issues...`)

              const result = await withRetry(
                () => github.rest.issues.listForRepo({
                  owner,
                  repo,
                  state: "open",
                  sort: "updated",
                  direction: "asc",
                  per_page: 100,
                  page,
                }),
                `Issues page ${page}`
              )

              if (!result.data.length) {
                break
              }

              core.info(`Page ${page}: fetched ${result.data.length} issues`)

              for (const issue of result.data) {
                const updated = new Date(issue.updated_at)

                if (updated >= issueCutoff) {
                  core.info(`Found fresh issue/PR #${issue.number} (${updated.toISOString()}), stopping issue scan`)
                  stop = true
                  break
                }

                if (issue.pull_request) {
                  core.info(`Skipping PR #${issue.number} in issue scan`)
                  continue
                }

                staleIssues.push({ ...issue, activity: updated })
              }

              if (!stop) {
                page++
                await sleep(SMALL_BATCH_DELAY_MS)
              }
            }

            core.info(`Found ${staleIssues.length} stale issues`)

            const issueDelayMs = staleIssues.length > SMALL_BATCH_THRESHOLD
              ? LARGE_BATCH_DELAY_MS
              : SMALL_BATCH_DELAY_MS

            core.info(`Using ${issueDelayMs}ms delay between issue operations (${staleIssues.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)

            let closedIssueCount = 0
            let skippedIssueCount = 0

            for (const issue of staleIssues) {
              const closeComment = `To stay organized issues are automatically closed after ${ISSUE_DAYS_INACTIVE} days of no activity. If the issue is still relevant please reopen it or create a fresh new one.`

              if (dryRun) {
                core.info(`[dry-run] Would close issue #${issue.number} (last activity: ${issue.activity.toISOString()} via updated): ${issue.title}`)
                continue
              }

              try {
                await withRetry(
                  () => github.rest.issues.createComment({
                    owner,
                    repo,
                    issue_number: issue.number,
                    body: closeComment,
                  }),
                  `Comment on issue #${issue.number}`
                )

                await withRetry(
                  () => github.rest.issues.update({
                    owner,
                    repo,
                    issue_number: issue.number,
                    state: "closed",
                    state_reason: "not_planned",
                  }),
                  `Close issue #${issue.number}`
                )

                closedIssueCount++
                core.info(`Closed issue #${issue.number} (last activity: ${issue.activity.toISOString()} via updated): ${issue.title}`)

                await sleep(issueDelayMs)
              } catch (error) {
                skippedIssueCount++
                core.error(`Failed to close issue #${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: ${closedPrCount}`)
            core.info(`PRs skipped (errors): ${skippedPrCount}`)
            core.info(`Stale issues identified: ${staleIssues.length}`)
            core.info(`Issues closed: ${closedIssueCount}`)
            core.info(`Issues skipped (errors): ${skippedIssueCount}`)
            core.info(`Elapsed time: ${elapsed}s`)
            core.info(`=============================`)
