mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
feat: add Kilo auto close workflow isolated from upstream (#11040)
This commit is contained in:
committed by
GitHub
parent
422bc8aca8
commit
7f84c13bce
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(`=============================`)
|
||||
Reference in New Issue
Block a user