From 7f84c13bcefcbcb453f8381606af4dafd1cd4cbe Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Tue, 9 Jun 2026 16:09:09 +0200 Subject: [PATCH] feat: add Kilo auto close workflow isolated from upstream (#11040) --- .github/workflows/close-issues.yml | 1 + .github/workflows/close-stale-prs.yml | 2 +- .github/workflows/kilo-auto-close.yml | 345 ++++++++++++++++++++++++++ script/check-workflows.ts | 1 + 4 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/kilo-auto-close.yml diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml index aba47e131f..c6575f750a 100644 --- a/.github/workflows/close-issues.yml +++ b/.github/workflows/close-issues.yml @@ -7,6 +7,7 @@ on: jobs: close: + if: github.repository == 'anomalyco/opencode' # kilocode_change - Kilo uses kilo-auto-close.yml runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index 38d0d3627a..13b0cf0219 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -17,7 +17,7 @@ permissions: jobs: close-stale-prs: - if: github.repository == 'Kilo-Org/kilocode' + if: github.repository == 'anomalyco/opencode' # kilocode_change - Kilo uses kilo-auto-close.yml runs-on: blacksmith-2vcpu-ubuntu-2404 # kilocode_change timeout-minutes: 15 steps: diff --git a/.github/workflows/kilo-auto-close.yml b/.github/workflows/kilo-auto-close.yml new file mode 100644 index 0000000000..65c87b9c4a --- /dev/null +++ b/.github/workflows/kilo-auto-close.yml @@ -0,0 +1,345 @@ +# 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 + } + } + } + } + } + } + ` + + 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`) + + const stalePrs = allPrs.filter((pr) => { + const dates = [ + new Date(pr.createdAt), + pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null, + pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null, + pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null, + ].filter((d) => d !== null) + + const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0] + + if (!lastActivity || lastActivity > prCutoff) { + core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`) + return false + } + + core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`) + return true + }) + + 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 open a new one.` + + if (dryRun) { + core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${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'}: ${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) + } + + 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 open a new one.` + + if (dryRun) { + core.info(`[dry-run] Would close issue #${issue.number}: ${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}: ${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(`=============================`) diff --git a/script/check-workflows.ts b/script/check-workflows.ts index 1fc0138d30..dbe4343a4c 100644 --- a/script/check-workflows.ts +++ b/script/check-workflows.ts @@ -43,6 +43,7 @@ const active = new Set([ "docs-check-links.yml", "duplicate-issues.yml", "generate.yml", + "kilo-auto-close.yml", "nix-eval.yml", "nix-hashes.yml", "prepare-jetbrains-release.yml",