diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml new file mode 100644 index 00000000000..886aeaaa8e6 --- /dev/null +++ b/.github/workflows/auto-close-duplicates.yml @@ -0,0 +1,33 @@ +name: Auto-close duplicate issues +description: Auto-closes issues that are duplicates of existing issues +on: + schedule: + - cron: "0 9 * * *" + workflow_dispatch: + +jobs: + auto-close-duplicates: + if: github.repository == 'BerriAI/litellm' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 (sha-pinned) + with: + bun-version: latest + + - name: Auto-close duplicate issues + run: bun run scripts/auto-close-duplicates.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} diff --git a/.github/workflows/close_stale_duplicate_issues.yml b/.github/workflows/close_stale_duplicate_issues.yml deleted file mode 100644 index c5f904d2b88..00000000000 --- a/.github/workflows/close_stale_duplicate_issues.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Close Stale Duplicate Issues - -# Closes issues that "Check Duplicate Issues" flagged and that nobody acknowledged -# within the grace period. Replying to the flag, thumbs-downing it, or applying an -# opt-out label all keep an issue open. -# -# Dry-run preview (touches nothing): -# gh workflow run "Close Stale Duplicate Issues" -f dry_run=true - -on: - schedule: - # Daily at 09:30 UTC, after the midnight stale sweep and off the hour. - - cron: "30 9 * * *" - workflow_dispatch: - inputs: - dry_run: - description: "Report what would close without touching any issue." - required: false - default: "false" - type: choice - options: - - "true" - - "false" - grace_period_days: - description: "Days to wait after the duplicate flag before closing." - required: false - default: "3" - limit: - description: "Maximum number of issues to close in a single run." - required: false - default: "50" - -permissions: - contents: read - issues: write - -jobs: - close-stale-duplicates: - if: github.repository == 'BerriAI/litellm' - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Close unacknowledged duplicates - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 - env: - DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} - GRACE_PERIOD_DAYS: ${{ github.event.inputs.grace_period_days || '3' }} - LIMIT: ${{ github.event.inputs.limit || '50' }} - with: - script: | - const FLAG_MARKER = '/; - const FLAG_LABEL = 'potential-duplicate'; - const OPTOUT_LABELS = ['do not close', 'keep open', 'not a duplicate']; - - const dryRun = process.env.DRY_RUN === 'true'; - const graceDays = Number(process.env.GRACE_PERIOD_DAYS); - const limit = Number(process.env.LIMIT); - const cutoff = Date.now() - graceDays * 86400000; - const { owner, repo } = context.repo; - - // Read candidates from the marker's digits-only field, never from the prose. - // Titles are user-controlled and get interpolated into this same comment, so - // scanning the body would let an issue titled "... see #1" redirect a closure - // onto an unrelated report. Take the lowest: the detector orders by score, not - // age, so the first candidate listed can be newer than the original report. - const canonicalTarget = (body, self) => { - const field = body.match(CANDIDATES); - if (!field) return null; - const refs = new Set(field[1].split(',').filter(Boolean).map(Number)); - refs.delete(self); - return refs.size ? Math.min(...refs) : null; - }; - - // Only issues the detector labelled: scanning the whole open backlog would - // cost one comments request each and exhaust the token's hourly budget. - const issues = await github.paginate(github.rest.issues.listForRepo, { - owner, repo, state: 'open', labels: FLAG_LABEL, per_page: 100, - }); - core.info(`Scanning ${issues.length} open issues labelled '${FLAG_LABEL}' in ${owner}/${repo}.`); - - const closures = []; - for (const issue of issues) { - const skip = (reason) => core.info(` #${issue.number}: skip, ${reason}`); - const labels = issue.labels.map((l) => (l.name || l).toLowerCase()); - const blocking = OPTOUT_LABELS.find((l) => labels.includes(l)); - if (blocking) { skip(`carries opt-out label '${blocking}'`); continue; } - - const comments = await github.paginate(github.rest.issues.listComments, { - owner, repo, issue_number: issue.number, per_page: 100, - }); - - // Author filter matters: "Quote reply" carries the marker into a human - // comment, and treating that as a fresh flag would restart the clock. - const flags = comments.filter((c) => c.user?.type === 'Bot' && c.body?.includes(FLAG_MARKER)); - if (!flags.length) { skip('never flagged as a potential duplicate'); continue; } - - const flag = flags.reduce((a, b) => (new Date(a.created_at) > new Date(b.created_at) ? a : b)); - const flaggedAt = new Date(flag.created_at).getTime(); - if (flaggedAt > cutoff) { skip(`flagged less than ${graceDays}d ago`); continue; } - - if (comments.some((c) => new Date(c.created_at).getTime() > flaggedAt)) { - skip('someone replied after the flag went up'); continue; - } - - const reactions = await github.paginate(github.rest.reactions.listForIssueComment, { - owner, repo, comment_id: flag.id, per_page: 100, - }); - if (reactions.some((r) => r.content === '-1' && r.user?.login === issue.user?.login)) { - skip('author thumbs-downed the flag'); continue; - } - - const target = canonicalTarget(flag.body, issue.number); - if (target === null) { skip('flag comment names no other issue number'); continue; } - if (target > issue.number) { skip(`only candidate #${target} is newer than this issue`); continue; } - - core.info(` #${issue.number}: unacknowledged duplicate of #${target}`); - closures.push({ number: issue.number, title: issue.title, target }); - } - - const actionable = closures.slice(0, limit); - if (closures.length > limit) { - core.info(`Reached limit ${limit}; ${closures.length - limit} further match(es) left for the next run.`); - } - - for (const { number, target } of actionable) { - if (dryRun) { core.info(` WOULD close #${number} as duplicate of #${target}`); continue; } - core.info(` closing #${number} as duplicate of #${target}`); - await github.rest.issues.createComment({ - owner, repo, issue_number: number, - body: `Closing as a duplicate of #${target}.\n\nThe duplicate notice on this issue went ` - + `unanswered for ${graceDays} days, so it is being closed automatically. If that call is ` - + `wrong, reopen the issue and say how it differs from #${target}, and we will pick it back ` - + `up.\n\n`, - }); - await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: ['duplicate'] }); - await github.rest.issues.update({ - owner, repo, issue_number: number, state: 'closed', state_reason: 'duplicate', - }); - } - - const heading = dryRun ? 'Would close as duplicates (dry run)' : 'Closed as duplicates'; - const rows = actionable.length - ? ['| Issue | Duplicate of |', '| --- | --- |', - ...actionable.map((c) => `| [#${c.number}](https://github.com/${owner}/${repo}/issues/${c.number}) ${c.title} | #${c.target} |`)] - : ['No issue reached the end of its grace period unacknowledged.']; - await core.summary - .addRaw([`## ${heading}`, '', ...rows, '', `Scanned ${issues.length} flagged issues.`].join('\n')) - .write(); - - core.info(`\n${dryRun ? 'Would close' : 'Closed'}: ${actionable.length}`); diff --git a/scripts/auto-close-duplicates.ts b/scripts/auto-close-duplicates.ts new file mode 100644 index 00000000000..1e94b009a1e --- /dev/null +++ b/scripts/auto-close-duplicates.ts @@ -0,0 +1,308 @@ +#!/usr/bin/env bun + +declare global { + var process: { + env: Record; + }; +} + +interface GitHubIssue { + number: number; + title: string; + user: { login: string }; + labels: { name: string }[]; +} + +interface GitHubComment { + id: number; + body: string; + created_at: string; + user: { type: string }; +} + +interface GitHubReaction { + user: { login: string }; + content: string; +} + +const FLAG_LABEL = "potential-duplicate"; +const FLAG_MARKER = "/; +const GRACE_PERIOD_DAYS = 3; + +async function githubRequest( + endpoint: string, + token: string, + method: string = "GET", + body?: any, +): Promise { + const response = await fetch(`https://api.github.com${endpoint}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "auto-close-duplicates-script", + ...(body && { "Content-Type": "application/json" }), + }, + ...(body && { body: JSON.stringify(body) }), + }); + + if (!response.ok) { + throw new Error( + `GitHub API request failed: ${response.status} ${response.statusText}`, + ); + } + + return response.json(); +} + +function extractDuplicateIssueNumber( + commentBody: string, + issueNumber: number, +): number | null { + // Read candidates from the marker's digits-only field, never from the prose. + // Titles are user-controlled and are interpolated into this same comment, so + // scanning the body would let an issue titled "... see #1" redirect a closure + // onto an unrelated report. + const field = commentBody.match(CANDIDATES); + if (!field) { + return null; + } + + const candidates = field[1] + .split(",") + .filter((value) => value !== "") + .map(Number) + .filter((value) => value !== issueNumber); + + // The detector orders candidates by score, not age, so the first one listed can + // be newer than the original report. Duplicates fold into the earliest issue. + return candidates.length > 0 ? Math.min(...candidates) : null; +} + +async function closeIssueAsDuplicate( + owner: string, + repo: string, + issueNumber: number, + duplicateOfNumber: number, + token: string, +): Promise { + await githubRequest( + `/repos/${owner}/${repo}/issues/${issueNumber}/comments`, + token, + "POST", + { + body: `This issue has been automatically closed as a duplicate of #${duplicateOfNumber}. + +The duplicate notice went unanswered for ${GRACE_PERIOD_DAYS} days. If this is incorrect, please re-open this issue and say how it differs from #${duplicateOfNumber}. + +`, + }, + ); + + // Added on its own endpoint rather than in the PATCH below, because sending + // `labels` with the state change replaces every label on the issue. + await githubRequest( + `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, + token, + "POST", + { labels: ["duplicate"] }, + ); + + await githubRequest( + `/repos/${owner}/${repo}/issues/${issueNumber}`, + token, + "PATCH", + { state: "closed", state_reason: "duplicate" }, + ); +} + +async function autoCloseDuplicates(): Promise { + console.log("[DEBUG] Starting auto-close duplicates script"); + + const token = process.env.GITHUB_TOKEN; + if (!token) { + throw new Error("GITHUB_TOKEN environment variable is required"); + } + console.log("[DEBUG] GitHub token found"); + + const owner = process.env.GITHUB_REPOSITORY_OWNER || "BerriAI"; + const repo = process.env.GITHUB_REPOSITORY_NAME || "litellm"; + console.log(`[DEBUG] Repository: ${owner}/${repo}`); + + const threeDaysAgo = new Date(); + threeDaysAgo.setDate(threeDaysAgo.getDate() - GRACE_PERIOD_DAYS); + console.log( + `[DEBUG] Checking for duplicate comments older than: ${threeDaysAgo.toISOString()}`, + ); + + // Only issues the detector labelled. Walking the whole open backlog would cost a + // comments request per issue, which on a four-figure backlog exhausts the Actions + // token's hourly rate limit for a handful of matches. + console.log(`[DEBUG] Fetching open issues labelled '${FLAG_LABEL}'...`); + const allIssues: GitHubIssue[] = []; + let page = 1; + const perPage = 100; + + while (true) { + const pageIssues: GitHubIssue[] = await githubRequest( + `/repos/${owner}/${repo}/issues?state=open&labels=${FLAG_LABEL}&per_page=${perPage}&page=${page}`, + token, + ); + + if (pageIssues.length === 0) break; + + allIssues.push(...pageIssues); + page++; + + // Safety limit to avoid infinite loops + if (page > 20) break; + } + + const issues = allIssues; + console.log(`[DEBUG] Found ${issues.length} flagged issues`); + + let processedCount = 0; + let candidateCount = 0; + + for (const issue of issues) { + processedCount++; + console.log( + `[DEBUG] Processing issue #${issue.number} (${processedCount}/${issues.length}): ${issue.title}`, + ); + + console.log(`[DEBUG] Fetching comments for issue #${issue.number}...`); + const comments: GitHubComment[] = await githubRequest( + `/repos/${owner}/${repo}/issues/${issue.number}/comments?per_page=100`, + token, + ); + console.log( + `[DEBUG] Issue #${issue.number} has ${comments.length} comments`, + ); + + // The author filter matters: GitHub's "Quote reply" carries the HTML marker into + // a human comment, and treating that as a fresh notice restarts the clock. + const dupeComments = comments.filter( + (comment) => + comment.body.includes(FLAG_MARKER) && comment.user.type === "Bot", + ); + console.log( + `[DEBUG] Issue #${issue.number} has ${dupeComments.length} duplicate detection comments`, + ); + + if (dupeComments.length === 0) { + console.log( + `[DEBUG] Issue #${issue.number} - no duplicate comments found, skipping`, + ); + continue; + } + + const lastDupeComment = dupeComments[dupeComments.length - 1]; + const dupeCommentDate = new Date(lastDupeComment.created_at); + console.log( + `[DEBUG] Issue #${issue.number} - most recent duplicate comment from: ${dupeCommentDate.toISOString()}`, + ); + + if (dupeCommentDate > threeDaysAgo) { + console.log( + `[DEBUG] Issue #${issue.number} - duplicate comment is too recent, skipping`, + ); + continue; + } + console.log( + `[DEBUG] Issue #${issue.number} - duplicate comment is old enough (${Math.floor( + (Date.now() - dupeCommentDate.getTime()) / (1000 * 60 * 60 * 24), + )} days)`, + ); + + const commentsAfterDupe = comments.filter( + (comment) => new Date(comment.created_at) > dupeCommentDate, + ); + console.log( + `[DEBUG] Issue #${issue.number} - ${commentsAfterDupe.length} comments after duplicate detection`, + ); + + if (commentsAfterDupe.length > 0) { + console.log( + `[DEBUG] Issue #${issue.number} - has activity after duplicate comment, skipping`, + ); + continue; + } + + console.log( + `[DEBUG] Issue #${issue.number} - checking reactions on duplicate comment...`, + ); + const reactions: GitHubReaction[] = await githubRequest( + `/repos/${owner}/${repo}/issues/comments/${lastDupeComment.id}/reactions?per_page=100`, + token, + ); + console.log( + `[DEBUG] Issue #${issue.number} - duplicate comment has ${reactions.length} reactions`, + ); + + const authorThumbsDown = reactions.some( + (reaction) => + reaction.user.login === issue.user.login && reaction.content === "-1", + ); + console.log( + `[DEBUG] Issue #${issue.number} - author thumbs down reaction: ${authorThumbsDown}`, + ); + + if (authorThumbsDown) { + console.log( + `[DEBUG] Issue #${issue.number} - author disagreed with duplicate detection, skipping`, + ); + continue; + } + + const duplicateIssueNumber = extractDuplicateIssueNumber( + lastDupeComment.body, + issue.number, + ); + if (!duplicateIssueNumber) { + console.log( + `[DEBUG] Issue #${issue.number} - could not extract duplicate issue number from comment, skipping`, + ); + continue; + } + + if (duplicateIssueNumber > issue.number) { + console.log( + `[DEBUG] Issue #${issue.number} - only candidate #${duplicateIssueNumber} is newer, skipping`, + ); + continue; + } + + candidateCount++; + const issueUrl = `https://github.com/${owner}/${repo}/issues/${issue.number}`; + + try { + console.log( + `[INFO] Auto-closing issue #${issue.number} as duplicate of #${duplicateIssueNumber}: ${issueUrl}`, + ); + await closeIssueAsDuplicate( + owner, + repo, + issue.number, + duplicateIssueNumber, + token, + ); + console.log( + `[SUCCESS] Successfully closed issue #${issue.number} as duplicate of #${duplicateIssueNumber}`, + ); + } catch (error) { + console.error( + `[ERROR] Failed to close issue #${issue.number} as duplicate: ${error}`, + ); + } + } + + console.log( + `[DEBUG] Script completed. Processed ${processedCount} issues, found ${candidateCount} candidates for auto-close`, + ); +} + +autoCloseDuplicates().catch(console.error); + +// Make it a module +export {};