From e2ffb6b01c52764fb31d9e931c64f4c53a14a747 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Wed, 26 Aug 2026 14:42:08 -0400 Subject: [PATCH 1/7] feat(ci): close duplicate issues after a 3-day grace period Duplicate detection already labelled and commented on new issues, and then closed them outright at 0.85 title similarity. That gave the reporter no chance to push back, and a title-similarity match is not strong enough evidence to close on its own. Detection now only flags. A new daily sweep closes a flagged issue three days later, and only if nobody engaged with the flag. Replying to it, thumbs-downing it, or applying an opt-out label all keep the issue open. The notice says all of that up front, so the reporter knows what happens and how to stop it. The two workflows hand off through an HTML marker in the comment body rather than its prose, so rewording the notice cannot silently break the sweep. The sweep lists by label instead of walking the whole backlog: 1663 open issues against 23 carrying the label meant a comments request each, which would burn the Actions token's hourly budget for a handful of matches. Candidates are taken as the lowest issue number, not the first one listed. The detector orders by score rather than age, so the first candidate can be newer than the issue being closed, and folding an original report into a later one is backwards. An issue whose only candidates are newer is skipped. Closures use state_reason=duplicate rather than not_planned, which reads as "see the other issue" instead of "we are not doing this". Also drops {{html_url}} from the notice. The detection action only exposes number, title and accuracy, so that placeholder had been rendering empty and every "similar issue" link in the comment pointed nowhere. --- .github/workflows/check_duplicate_issues.yml | 38 ++--- .../close_stale_duplicate_issues.yml | 148 ++++++++++++++++++ 2 files changed, 158 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/close_stale_duplicate_issues.yml diff --git a/.github/workflows/check_duplicate_issues.yml b/.github/workflows/check_duplicate_issues.yml index 78198b2c7bb..a087007bff3 100644 --- a/.github/workflows/check_duplicate_issues.yml +++ b/.github/workflows/check_duplicate_issues.yml @@ -1,5 +1,10 @@ name: Check Duplicate Issues +# Flags newly opened issues that look like existing ones. Flagging only: the actual +# close happens 3 days later in "Close Stale Duplicate Issues", and only if nobody +# replied to the comment posted here. The HTML marker below is the handshake between +# the two workflows, so keep it in the template. + on: issues: types: [opened, edited] @@ -19,35 +24,12 @@ jobs: threshold: 0.6 reaction: eyes comment: | - **⚠️ Potential duplicate detected** + + **Potential duplicate detected** - This issue appears similar to existing issue(s): + This looks similar to: {{#issues}} - - [#{{number}}]({{html_url}}) - {{title}} ({{accuracy}}% similar) + - #{{number}} - {{title}} ({{accuracy}}% similar) {{/issues}} - Please review the linked issue(s) to see if they address your concern. If this is not a duplicate, please provide additional context to help us understand the difference. - - - name: Checkout close script - if: github.event.action == 'opened' - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - sparse-checkout: .github/scripts - persist-credentials: false - - - name: Set up Python - if: github.event.action == 'opened' - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.12" - - - name: Auto-close if high-confidence duplicate - if: github.event.action == 'opened' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - python3 .github/scripts/close_duplicate_issues.py \ - --issue-number ${{ github.event.issue.number }} \ - --repo ${{ github.repository }} \ - --threshold 0.85 \ - --close + This issue will close automatically in 3 days unless someone responds. If it is a duplicate, please 👍 the existing issue and follow along there. If it is not, comment here or 👎 this comment and it stays open. diff --git a/.github/workflows/close_stale_duplicate_issues.yml b/.github/workflows/close_stale_duplicate_issues.yml new file mode 100644 index 00000000000..8bc1af90454 --- /dev/null +++ b/.github/workflows/close_stale_duplicate_issues.yml @@ -0,0 +1,148 @@ +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; + + // The oldest issue the flag points at, excluding the issue itself. The + // detector orders candidates by score, not age, so the first one listed + // can be newer than the original report. + const canonicalTarget = (body, self) => { + const refs = new Set(); + for (const [, n] of body.matchAll(/#(\d+)/g)) refs.add(Number(n)); + for (const [, n] of body.matchAll(/github\.com\/[^/\s]+\/[^/\s]+\/issues\/(\d+)/g)) refs.add(Number(n)); + 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}`); From af340c02402a3c0f989c388f217d5d974603809f Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Wed, 26 Aug 2026 15:04:35 -0400 Subject: [PATCH 2/7] fix(ci): read duplicate candidates from the marker, not the notice prose The notice interpolates each candidate's title, and the sweep scanned the whole comment for issue references and took the lowest. Titles are attacker-controlled, so filing a candidate titled "... see #1" redirected the closure: any later report matching that candidate would be closed as a duplicate of #1 instead. The detector now emits the candidate numbers as a digits-only field inside the marker, built from the API's number field, and the sweep reads only that. Prose is never parsed, so nothing a reporter can type reaches the target selection. --- .github/workflows/check_duplicate_issues.yml | 2 +- .../workflows/close_stale_duplicate_issues.yml | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/check_duplicate_issues.yml b/.github/workflows/check_duplicate_issues.yml index a087007bff3..71d2a3b75eb 100644 --- a/.github/workflows/check_duplicate_issues.yml +++ b/.github/workflows/check_duplicate_issues.yml @@ -24,7 +24,7 @@ jobs: threshold: 0.6 reaction: eyes comment: | - + **Potential duplicate detected** This looks similar to: diff --git a/.github/workflows/close_stale_duplicate_issues.yml b/.github/workflows/close_stale_duplicate_issues.yml index 8bc1af90454..c5f904d2b88 100644 --- a/.github/workflows/close_stale_duplicate_issues.yml +++ b/.github/workflows/close_stale_duplicate_issues.yml @@ -48,7 +48,8 @@ jobs: LIMIT: ${{ github.event.inputs.limit || '50' }} with: script: | - const FLAG_MARKER = ''; + const FLAG_MARKER = '/; const FLAG_LABEL = 'potential-duplicate'; const OPTOUT_LABELS = ['do not close', 'keep open', 'not a duplicate']; @@ -58,13 +59,15 @@ jobs: const cutoff = Date.now() - graceDays * 86400000; const { owner, repo } = context.repo; - // The oldest issue the flag points at, excluding the issue itself. The - // detector orders candidates by score, not age, so the first one listed - // can be newer than the original report. + // 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 refs = new Set(); - for (const [, n] of body.matchAll(/#(\d+)/g)) refs.add(Number(n)); - for (const [, n] of body.matchAll(/github\.com\/[^/\s]+\/[^/\s]+\/issues\/(\d+)/g)) refs.add(Number(n)); + 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; }; From 3ea11b64e65628f07b47a2f0e0853e79b1ff8334 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Wed, 26 Aug 2026 15:18:06 -0400 Subject: [PATCH 3/7] refactor(ci): run the duplicate sweep as a Bun TypeScript script Moves the sweep out of inline workflow JavaScript and into scripts/, following the layout anthropics/claude-code uses for the same job: a checked-out repo, a sha-pinned setup-bun step, and `bun run scripts/auto-close-duplicates.ts`. The script mirrors that repo's file shape, keeping the same request helper, interfaces, per-issue debug logging, and top-level catch, so the two read the same way side by side. Two things stay deliberately different. Candidates come from the notice marker's digits-only field rather than a regex over the comment prose, because titles are attacker-controlled and are interpolated into that same comment. The label is also added on its own endpoint instead of alongside the state change, since sending labels with a PATCH replaces every label already on the issue. --- .github/workflows/auto-close-duplicates.yml | 33 ++ .../close_stale_duplicate_issues.yml | 151 --------- scripts/auto-close-duplicates.ts | 308 ++++++++++++++++++ 3 files changed, 341 insertions(+), 151 deletions(-) create mode 100644 .github/workflows/auto-close-duplicates.yml delete mode 100644 .github/workflows/close_stale_duplicate_issues.yml create mode 100644 scripts/auto-close-duplicates.ts 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 {}; From f4542d960511368eaeab50f68580a89aa13903e6 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Wed, 26 Aug 2026 15:26:02 -0400 Subject: [PATCH 4/7] fix(ci): pin the Bun runtime instead of tracking latest The setup step ran `bun-version: latest`, carried over from the upstream layout, and the step after it holds an issues: write token. A compromised Bun release would have executed privileged in that job and could rewrite or close issues. Pinned to 1.4.0, the release the passing runs already resolved to. setup-bun takes no checksum input, so pinning the action by sha and the runtime by exact version is as far as this can be hardened without hand-rolling the download. --- .github/workflows/auto-close-duplicates.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml index 886aeaaa8e6..ff3b5eff7c2 100644 --- a/.github/workflows/auto-close-duplicates.yml +++ b/.github/workflows/auto-close-duplicates.yml @@ -23,7 +23,10 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 (sha-pinned) with: - bun-version: latest + # Exact version, never latest: the next step holds an issues: write token, + # so a compromised Bun release would run privileged here. setup-bun exposes + # no checksum input, so pinning the action and the version is the ceiling. + bun-version: "1.4.0" - name: Auto-close duplicate issues run: bun run scripts/auto-close-duplicates.ts From f118511f5562eff67d0473346056d3bd6bbf06e1 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Wed, 26 Aug 2026 15:35:36 -0400 Subject: [PATCH 5/7] fix(ci): honour a duplicate-notice thumbs down from anyone The notice tells every reader that a thumbs down keeps the issue open, but the sweep only counted the reaction when it came from the issue author. A maintainer or another affected user could follow the instruction exactly and still watch the issue close, which made the notice a promise the sweep did not keep. Any thumbs down now spares the issue. That buys back nothing an abuser did not already have: a plain comment stops the clock for anyone, so restricting the reaction only ever penalised people who did what they were told. Drops the issue and reaction author fields, since nothing reads them now. --- scripts/auto-close-duplicates.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/scripts/auto-close-duplicates.ts b/scripts/auto-close-duplicates.ts index 1e94b009a1e..6e361af3e24 100644 --- a/scripts/auto-close-duplicates.ts +++ b/scripts/auto-close-duplicates.ts @@ -9,7 +9,6 @@ declare global { interface GitHubIssue { number: number; title: string; - user: { login: string }; labels: { name: string }[]; } @@ -21,7 +20,6 @@ interface GitHubComment { } interface GitHubReaction { - user: { login: string }; content: string; } @@ -240,17 +238,17 @@ async function autoCloseDuplicates(): Promise { `[DEBUG] Issue #${issue.number} - duplicate comment has ${reactions.length} reactions`, ); - const authorThumbsDown = reactions.some( - (reaction) => - reaction.user.login === issue.user.login && reaction.content === "-1", - ); + // Any thumbs down, not just the author's. The notice tells every reader that a + // 👎 keeps the issue open, and anyone can already stop the clock by commenting, + // so honouring only the author would make the notice a lie without buying safety. + const thumbsDown = reactions.some((reaction) => reaction.content === "-1"); console.log( - `[DEBUG] Issue #${issue.number} - author thumbs down reaction: ${authorThumbsDown}`, + `[DEBUG] Issue #${issue.number} - thumbs down reaction: ${thumbsDown}`, ); - if (authorThumbsDown) { + if (thumbsDown) { console.log( - `[DEBUG] Issue #${issue.number} - author disagreed with duplicate detection, skipping`, + `[DEBUG] Issue #${issue.number} - someone disagreed with duplicate detection, skipping`, ); continue; } From 539bc8ef929e93603e2f99d8b5529cb5eb13a14c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:02:07 -0700 Subject: [PATCH 6/7] fix(ci): close only identical-title duplicates, dry-run the sweep, reopen on reply The merged detector's 0.6 flag threshold had become the close bar, and 6 of the 7 real flagged pairs at 85% or more were not duplicates. The sweep now closes only when an older open issue has the identical normalized title, measures the grace period from the latest bot notice, and leaves the issue open when anyone replies or gives the notice a thumbs down. A reporter cannot reopen an issue the bot closed, so a reporter comment after the automatic close reopens it, drops the duplicate label, and asks for a human look. Manual dispatch defaults to a dry run and takes a grace_period_days input, the runner supplies the repository, the dead python closer is gone, and the decision core has bun tests on a PR-triggered job. --- .github/scripts/close_duplicate_issues.py | 230 -------- .github/workflows/auto-close-duplicates.yml | 59 +- .github/workflows/check_duplicate_issues.yml | 14 +- scripts/auto-close-duplicates.test.ts | 327 +++++++++++ scripts/auto-close-duplicates.ts | 543 +++++++++---------- 5 files changed, 647 insertions(+), 526 deletions(-) delete mode 100755 .github/scripts/close_duplicate_issues.py create mode 100644 scripts/auto-close-duplicates.test.ts diff --git a/.github/scripts/close_duplicate_issues.py b/.github/scripts/close_duplicate_issues.py deleted file mode 100755 index ec522af4f88..00000000000 --- a/.github/scripts/close_duplicate_issues.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env python3 -""" -Detect and close duplicate GitHub issues using title similarity. - -Modes: - --scan Compare all open issues against each other (batch) - --issue-number N Check a single issue against older open issues - -Requires the `gh` CLI to be authenticated. -""" - -import argparse -import difflib -import json -import re -import subprocess -import sys - - -def normalize_title(title: str) -> str: - """Strip common prefixes, lowercase, and collapse whitespace.""" - title = re.sub( - r"^\[?(bug|feature request|enhancement|question|docs)[:\]]?\s*", - "", - title, - flags=re.IGNORECASE, - ) - return " ".join(title.lower().split()) - - -def gh(*args: str) -> str: - """Run a gh CLI command and return stdout.""" - result = subprocess.run( - ["gh", *args], - capture_output=True, - text=True, - check=True, - ) - return result.stdout - - -def fetch_open_issues(repo: str | None) -> list[dict]: - """Fetch all open issues (excluding PRs) via gh api --paginate.""" - if repo: - endpoint = ( - f"repos/{repo}/issues?state=open&per_page=100&sort=created&direction=asc" - ) - else: - endpoint = "repos/{owner}/{repo}/issues?state=open&per_page=100&sort=created&direction=asc" - cmd = ["api", "--paginate", endpoint] - - raw = gh(*cmd) - # gh --paginate concatenates JSON arrays, so we may get multiple arrays - issues = [] - for line in raw.strip().splitlines(): - line = line.strip() - if not line: - continue - parsed = json.loads(line) - if isinstance(parsed, list): - issues.extend(parsed) - else: - issues.append(parsed) - - # Filter out pull requests (they also appear in the issues endpoint) - return [i for i in issues if "pull_request" not in i] - - -def close_as_duplicate( - issue_number: int, duplicate_of: int, repo: str | None, dry_run: bool -) -> None: - """Close an issue as duplicate of another, adding a comment and label.""" - repo_args = ["--repo", repo] if repo else [] - - if dry_run: - print( - f" [DRY RUN] Would close #{issue_number} as duplicate of #{duplicate_of}" - ) - return - - # Add comment - comment_body = ( - f"Closing as duplicate of #{duplicate_of}.\n\n" - "If you believe this is not a duplicate, please reopen and add context " - "explaining how this differs." - ) - gh("issue", "comment", str(issue_number), "--body", comment_body, *repo_args) - - # Add label - gh("issue", "edit", str(issue_number), "--add-label", "duplicate", *repo_args) - - # Close with not_planned reason - gh( - "api", - f"repos/{repo or '{owner}/{repo}'}/issues/{issue_number}", - "-X", - "PATCH", - "-f", - "state=closed", - "-f", - "state_reason=not_planned", - ) - - print(f" Closed #{issue_number} as duplicate of #{duplicate_of}") - - -def find_duplicate( - issue: dict, candidates: list[dict], threshold: float -) -> dict | None: - """Return the first candidate whose normalized title is above threshold.""" - norm = normalize_title(issue["title"]) - for candidate in candidates: - if candidate["number"] == issue["number"]: - continue - cand_norm = normalize_title(candidate["title"]) - ratio = difflib.SequenceMatcher(None, norm, cand_norm).ratio() - if ratio >= threshold: - return candidate - return None - - -def scan_all( - issues: list[dict], threshold: float, repo: str | None, dry_run: bool -) -> int: - """Compare every issue against all older issues. Returns count of duplicates found.""" - # Sort oldest first - issues.sort(key=lambda i: i["number"]) - closed_count = 0 - - for idx, issue in enumerate(issues): - older = issues[:idx] - if not older: - continue - dup = find_duplicate(issue, older, threshold) - if dup: - ratio = difflib.SequenceMatcher( - None, - normalize_title(issue["title"]), - normalize_title(dup["title"]), - ).ratio() - print( - f"#{issue['number']}: \"{issue['title']}\"\n" - f" -> duplicate of #{dup['number']}: \"{dup['title']}\" " - f"({ratio:.0%} similar)" - ) - close_as_duplicate(issue["number"], dup["number"], repo, dry_run) - closed_count += 1 - - return closed_count - - -def check_single( - issue_number: int, - issues: list[dict], - threshold: float, - repo: str | None, - dry_run: bool, -) -> bool: - """Check a single issue against all older open issues. Returns True if duplicate found.""" - target = None - for i in issues: - if i["number"] == issue_number: - target = i - break - - if target is None: - print(f"Issue #{issue_number} not found among open issues.") - return False - - older = [i for i in issues if i["number"] < issue_number] - dup = find_duplicate(target, older, threshold) - if dup: - ratio = difflib.SequenceMatcher( - None, - normalize_title(target["title"]), - normalize_title(dup["title"]), - ).ratio() - print( - f"#{target['number']}: \"{target['title']}\"\n" - f" -> duplicate of #{dup['number']}: \"{dup['title']}\" " - f"({ratio:.0%} similar)" - ) - close_as_duplicate(issue_number, dup["number"], repo, dry_run) - return True - - print(f"#{issue_number}: no duplicate found above threshold {threshold}") - return False - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Detect and close duplicate GitHub issues" - ) - mode = parser.add_mutually_exclusive_group(required=True) - mode.add_argument("--scan", action="store_true", help="Scan all open issues") - mode.add_argument("--issue-number", type=int, help="Check a single issue number") - parser.add_argument( - "--threshold", type=float, default=0.85, help="Similarity threshold (0-1)" - ) - parser.add_argument( - "--close", - action="store_true", - help="Actually close duplicates (default is dry-run)", - ) - parser.add_argument( - "--repo", type=str, help="Repository (owner/repo). Auto-detected if omitted." - ) - args = parser.parse_args() - - dry_run = not args.close - - if dry_run: - print("=== DRY RUN MODE (pass --close to actually close issues) ===\n") - - print("Fetching open issues...") - issues = fetch_open_issues(args.repo) - print(f"Found {len(issues)} open issues.\n") - - if args.scan: - count = scan_all(issues, args.threshold, args.repo, dry_run) - print(f"\nTotal duplicates {'found' if dry_run else 'closed'}: {count}") - else: - found = check_single( - args.issue_number, issues, args.threshold, args.repo, dry_run - ) - sys.exit(0 if found else 0) # Always exit 0; finding no dup is not an error - - -if __name__ == "__main__": - main() diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml index ff3b5eff7c2..d8256917805 100644 --- a/.github/workflows/auto-close-duplicates.yml +++ b/.github/workflows/auto-close-duplicates.yml @@ -1,19 +1,33 @@ name: Auto-close duplicate issues -description: Auto-closes issues that are duplicates of existing issues + on: schedule: - cron: "0 9 * * *" workflow_dispatch: + inputs: + dry_run: + description: Log which issues would close without closing anything + type: boolean + default: true + grace_period_days: + description: Days a duplicate notice must go unanswered before the close + type: number + default: 3 + pull_request: + paths: + - .github/workflows/auto-close-duplicates.yml + - scripts/auto-close-duplicates.ts + - scripts/auto-close-duplicates.test.ts + +permissions: {} jobs: - auto-close-duplicates: - if: github.repository == 'BerriAI/litellm' + test: + if: github.event_name == 'pull_request' runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 5 permissions: contents: read - issues: write - steps: - name: Checkout repository uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 @@ -21,16 +35,35 @@ jobs: persist-credentials: false - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 (sha-pinned) + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - # Exact version, never latest: the next step holds an issues: write token, - # so a compromised Bun release would run privileged here. setup-bun exposes - # no checksum input, so pinning the action and the version is the ceiling. bun-version: "1.4.0" - - name: Auto-close duplicate issues + - name: Test the sweep + run: bun test scripts/auto-close-duplicates.test.ts + + sweep: + if: github.event_name != 'pull_request' && 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 + with: + # Exact version, never latest: the next step holds an issues: write token + bun-version: "1.4.0" + + - name: Close unanswered duplicates, reopen ones the reporter answered 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 }} + DRY_RUN: ${{ inputs.dry_run == true }} + GRACE_PERIOD_DAYS: ${{ inputs.grace_period_days }} diff --git a/.github/workflows/check_duplicate_issues.yml b/.github/workflows/check_duplicate_issues.yml index 71d2a3b75eb..41ec43a1d9b 100644 --- a/.github/workflows/check_duplicate_issues.yml +++ b/.github/workflows/check_duplicate_issues.yml @@ -1,17 +1,19 @@ name: Check Duplicate Issues -# Flags newly opened issues that look like existing ones. Flagging only: the actual -# close happens 3 days later in "Close Stale Duplicate Issues", and only if nobody -# replied to the comment posted here. The HTML marker below is the handshake between -# the two workflows, so keep it in the template. +# Flagging only. "Auto-close duplicate issues" closes a flagged issue 3 days later, +# and only when its title is identical to an older open issue and nobody replied. +# The HTML marker below is the handshake between the two, so keep it in the template. on: issues: types: [opened, edited] +permissions: {} + jobs: check-duplicate: runs-on: ubuntu-latest + timeout-minutes: 5 permissions: issues: write contents: read @@ -29,7 +31,7 @@ jobs: This looks similar to: {{#issues}} - - #{{number}} - {{title}} ({{accuracy}}% similar) + - #{{number}} - {{title}} {{/issues}} - This issue will close automatically in 3 days unless someone responds. If it is a duplicate, please 👍 the existing issue and follow along there. If it is not, comment here or 👎 this comment and it stays open. + If this is a duplicate, add a thumbs-up reaction to the existing issue and follow along there. When the title is identical to an older open issue, this issue closes automatically in 3 days unless someone responds. If it is not a duplicate, comment here or add a thumbs-down reaction to this comment and it stays open. diff --git a/scripts/auto-close-duplicates.test.ts b/scripts/auto-close-duplicates.test.ts new file mode 100644 index 00000000000..6a7a3a507bd --- /dev/null +++ b/scripts/auto-close-duplicates.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, test } from "bun:test"; + +import { + CLOSED_MARKER, + REOPEN_COMMENT, + candidateNumbers, + duplicateTarget, + normalizeTitle, + pendingNotice, + readConfig, + reopenTarget, + sweepClosedIssue, + sweepIssue, + type Comment, + type GitHubApi, + type Issue, + type SweepConfig, +} from "./auto-close-duplicates"; + +const NOW = new Date("2026-09-04T09:00:00Z"); +const DAY_MS = 24 * 60 * 60 * 1000; +const daysAgo = (days: number): string => new Date(NOW.getTime() - days * DAY_MS).toISOString(); + +const issue = (number: number, title: string, overrides: Partial = {}): Issue => ({ + number, + title, + state: "open", + user: { login: "reporter" }, + ...overrides, +}); + +const notice = (candidates: readonly number[], createdAt: string, overrides: Partial = {}): Comment => ({ + id: 900, + body: `\n**Potential duplicate detected**`, + created_at: createdAt, + user: { type: "Bot", login: "github-actions[bot]" }, + ...overrides, +}); + +const humanComment = (createdAt: string, body = "It is not the same thing", login = "reporter"): Comment => ({ + id: 901, + body, + created_at: createdAt, + user: { type: "User", login }, +}); + +const config: SweepConfig = { repo: "BerriAI/litellm", graceDays: 3, dryRun: false, now: NOW }; + +describe("normalizeTitle", () => { + test("drops the template prefix, case, and punctuation", () => { + expect(normalizeTitle("[Bug]: Gemma 4-e4b fails on Vertex!")).toBe("gemma 4 e4b fails on vertex"); + expect(normalizeTitle("[Feature]: ")).toBe(""); + }); +}); + +describe("candidateNumbers", () => { + test("reads only the marker field, keeps older issues, sorted ascending and deduplicated", () => { + const body = "\n- #1 - see #1 (100% similar)"; + expect(candidateNumbers(body, 35)).toEqual([10, 30]); + }); + + test("returns nothing without the marker", () => { + expect(candidateNumbers("- #1 - looks like #1", 35)).toEqual([]); + }); +}); + +describe("pendingNotice", () => { + test("waits out the grace period from the latest notice", () => { + const fresh = pendingNotice(issue(35, "t"), [notice([10], daysAgo(2.9))], config); + expect(fresh.kind).toBe("skip"); + const aged = pendingNotice(issue(35, "t"), [notice([10], daysAgo(3.1))], config); + expect(aged.kind).toBe("pending"); + const reposted = pendingNotice( + issue(35, "t"), + [notice([10], daysAgo(6)), notice([10], daysAgo(1), { id: 902 })], + config, + ); + expect(reposted.kind).toBe("skip"); + }); + + test("a zero-day grace period acts on the notice at once", () => { + const verdict = pendingNotice(issue(35, "t"), [notice([10], daysAgo(0.01))], { ...config, graceDays: 0 }); + expect(verdict.kind).toBe("pending"); + }); + + test("a human reply after the notice keeps the issue open, a bot reply does not", () => { + const human = pendingNotice(issue(35, "t"), [notice([10], daysAgo(5)), humanComment(daysAgo(4))], config); + expect(human).toEqual({ kind: "skip", reason: "someone replied after the notice" }); + const bot = pendingNotice( + issue(35, "t"), + [notice([10], daysAgo(5)), { id: 903, body: "triage", created_at: daysAgo(4), user: { type: "Bot", login: "triage[bot]" } }], + config, + ); + expect(bot.kind).toBe("pending"); + }); + + test("a human quoting the marker is not a notice", () => { + const quoted = pendingNotice(issue(35, "t"), [notice([10], daysAgo(5), { user: { type: "User", login: "reporter" } })], config); + expect(quoted).toEqual({ kind: "skip", reason: "carries no duplicate notice" }); + }); + + test("never closes an issue twice: a reopened issue is left alone", () => { + const reopened = pendingNotice( + issue(35, "t"), + [notice([10], daysAgo(9)), { id: 904, body: `Closed automatically\n\n${CLOSED_MARKER}`, created_at: daysAgo(5), user: { type: "Bot", login: "github-actions[bot]" } }], + config, + ); + expect(reopened).toEqual({ kind: "skip", reason: "was reopened after an automatic close" }); + }); + + test("skips pull requests and issues whose only candidates are newer", () => { + expect(pendingNotice(issue(35, "t", { pull_request: {} }), [notice([10], daysAgo(5))], config).kind).toBe("skip"); + expect(pendingNotice(issue(35, "t"), [notice([40], daysAgo(5))], config)).toEqual({ + kind: "skip", + reason: "no candidate is older than this issue", + }); + }); +}); + +describe("duplicateTarget", () => { + const reporter = issue(35, "[Bug]: Gemma 4-e4b fails on Vertex"); + + test("closes only against the earliest open issue with the identical normalized title", () => { + const verdict = duplicateTarget( + reporter, + [issue(10, "[Bug]: Gemma 4-e4n fails on Vertex"), issue(20, "[bug]: gemma 4-e4b fails on vertex"), issue(30, "[Bug]: Gemma 4-e4b fails on Vertex")], + [], + ); + expect(verdict).toEqual({ kind: "close", duplicateOf: 20 }); + }); + + test("a near miss in the title is not a duplicate", () => { + const verdict = duplicateTarget(reporter, [issue(10, "[Bug]: Gemma 4-e4n fails on Vertex")], []); + expect(verdict).toEqual({ kind: "skip", reason: "no older open issue has the identical title" }); + }); + + test("bare template titles never match each other", () => { + const verdict = duplicateTarget(issue(35, "[Bug]: "), [issue(10, "[Bug]: ")], []); + expect(verdict.kind).toBe("skip"); + expect(verdict.kind === "skip" && verdict.reason).toContain("too short"); + }); + + test("a closed candidate or a pull request is never the target", () => { + expect(duplicateTarget(reporter, [issue(10, reporter.title, { state: "closed" })], []).kind).toBe("skip"); + expect(duplicateTarget(reporter, [issue(10, reporter.title, { pull_request: {} })], []).kind).toBe("skip"); + }); + + test("a thumbs down on the notice keeps the issue open", () => { + const verdict = duplicateTarget(reporter, [issue(10, reporter.title)], [{ content: "+1" }, { content: "-1" }]); + expect(verdict).toEqual({ kind: "skip", reason: "someone gave the notice a thumbs down" }); + }); +}); + +describe("sweepIssue", () => { + const reporter = issue(35, "[Bug]: Gemma 4-e4b fails on Vertex"); + const original = issue(10, "[Bug]: Gemma 4-e4b fails on Vertex"); + + function fakeApi(): { readonly api: GitHubApi; readonly writes: readonly string[] } { + const writes: string[] = []; + const api: GitHubApi = { + request: async (method: string, path: string, body?: object): Promise => { + if (method !== "GET") { + writes.push(`${method} ${path} ${JSON.stringify(body)}`); + return {} as T; + } + if (path.startsWith("/repos/BerriAI/litellm/issues/35/comments")) { + return [notice([10], daysAgo(5))] as T; + } + if (path.startsWith("/repos/BerriAI/litellm/issues/comments/900/reactions")) { + return [] as T; + } + if (path === "/repos/BerriAI/litellm/issues/10") { + return original as T; + } + throw new Error(`unexpected GET ${path}`); + }, + }; + return { api, writes }; + } + + test("a dry run reports the close and writes nothing", async () => { + const { api, writes } = fakeApi(); + const verdict = await sweepIssue(api, { ...config, dryRun: true }, reporter); + expect(verdict).toEqual({ kind: "close", duplicateOf: 10 }); + expect(writes).toEqual([]); + }); + + test("a real run comments, labels, then closes with the duplicate reason", async () => { + const { api, writes } = fakeApi(); + const verdict = await sweepIssue(api, config, reporter); + expect(verdict).toEqual({ kind: "close", duplicateOf: 10 }); + expect(writes.map((write) => write.split(" ").slice(0, 2).join(" "))).toEqual([ + "POST /repos/BerriAI/litellm/issues/35/comments", + "POST /repos/BerriAI/litellm/issues/35/labels", + "PATCH /repos/BerriAI/litellm/issues/35", + ]); + expect(writes[0]).toContain("duplicate of #10"); + expect(writes[0]).toContain("unanswered for 3 days"); + expect(writes[0]).toContain(CLOSED_MARKER); + expect(writes[1]).toContain('{"labels":["duplicate"]}'); + expect(writes[2]).toContain('{"state":"closed","state_reason":"duplicate"}'); + }); +}); + +describe("reopenTarget", () => { + const closedByBot = (overrides: Partial = {}): Issue => + issue(35, "t", { state: "closed", closed_by: { type: "Bot" }, ...overrides }); + const closeMarker = (createdAt: string): Comment => ({ + id: 905, + body: `Closed automatically as a duplicate of #10.\n\n${CLOSED_MARKER}`, + created_at: createdAt, + user: { type: "Bot", login: "github-actions[bot]" }, + }); + + test("a reporter reply after the automatic close reopens", () => { + const verdict = reopenTarget(closedByBot(), [closeMarker(daysAgo(2)), humanComment(daysAgo(1))]); + expect(verdict).toEqual({ kind: "reopen" }); + }); + + test("an issue closed by a person stays closed", () => { + const verdict = reopenTarget(closedByBot({ closed_by: { type: "User" } }), [ + closeMarker(daysAgo(2)), + humanComment(daysAgo(1)), + ]); + expect(verdict).toEqual({ kind: "skip", reason: "was closed by a person" }); + }); + + test("without the automatic-close marker nothing reopens", () => { + const verdict = reopenTarget(closedByBot(), [humanComment(daysAgo(1))]); + expect(verdict).toEqual({ kind: "skip", reason: "carries no automatic-close marker" }); + }); + + test("a maintainer reply alone does not reopen", () => { + const verdict = reopenTarget(closedByBot(), [ + closeMarker(daysAgo(2)), + humanComment(daysAgo(1), "Confirmed duplicate", "maintainer"), + ]); + expect(verdict).toEqual({ kind: "skip", reason: "the reporter has not replied since the close" }); + }); + + test("a reporter comment from before the close does not reopen", () => { + const verdict = reopenTarget(closedByBot(), [humanComment(daysAgo(3)), closeMarker(daysAgo(2))]); + expect(verdict).toEqual({ kind: "skip", reason: "the reporter has not replied since the close" }); + }); + + test("a pull request never reopens", () => { + const verdict = reopenTarget(closedByBot({ pull_request: {} }), [closeMarker(daysAgo(2)), humanComment(daysAgo(1))]); + expect(verdict).toEqual({ kind: "skip", reason: "is a pull request" }); + }); +}); + +describe("sweepClosedIssue", () => { + function fakeApi(issueBody: Issue, comments: readonly Comment[]): { readonly api: GitHubApi; readonly writes: readonly string[] } { + const writes: string[] = []; + const api: GitHubApi = { + request: async (method: string, path: string, body?: object): Promise => { + if (method !== "GET") { + writes.push(`${method} ${path} ${JSON.stringify(body)}`); + return {} as T; + } + if (path.startsWith("/repos/BerriAI/litellm/issues/35/comments")) { + return comments as T; + } + if (path === "/repos/BerriAI/litellm/issues/35") { + return issueBody as T; + } + throw new Error(`unexpected GET ${path}`); + }, + }; + return { api, writes }; + } + + const closedByBot = issue(35, "t", { state: "closed", closed_by: { type: "Bot" } }); + const closeMarker: Comment = { + id: 905, + body: `Closed automatically as a duplicate of #10.\n\n${CLOSED_MARKER}`, + created_at: daysAgo(2), + user: { type: "Bot", login: "github-actions[bot]" }, + }; + + test("a real run unlabels, reopens, then explains", async () => { + const { api, writes } = fakeApi(closedByBot, [closeMarker, humanComment(daysAgo(1))]); + const verdict = await sweepClosedIssue(api, config, 35); + expect(verdict).toEqual({ kind: "reopen" }); + expect(writes).toEqual([ + "DELETE /repos/BerriAI/litellm/issues/35/labels/duplicate undefined", + 'PATCH /repos/BerriAI/litellm/issues/35 {"state":"open"}', + `POST /repos/BerriAI/litellm/issues/35/comments {"body":"${REOPEN_COMMENT}"}`, + ]); + }); + + test("a dry run reports the reopen and writes nothing", async () => { + const { api, writes } = fakeApi(closedByBot, [closeMarker, humanComment(daysAgo(1))]); + const verdict = await sweepClosedIssue(api, { ...config, dryRun: true }, 35); + expect(verdict).toEqual({ kind: "reopen" }); + expect(writes).toEqual([]); + }); +}); + +describe("readConfig", () => { + test("defaults to a real run with a 3-day grace period", () => { + const parsed = readConfig({ GITHUB_TOKEN: "t", GITHUB_REPOSITORY: "BerriAI/litellm" }, NOW); + expect(parsed).toEqual({ token: "t", repo: "BerriAI/litellm", graceDays: 3, dryRun: false, now: NOW }); + }); + + test("honors DRY_RUN and GRACE_PERIOD_DAYS overrides", () => { + const parsed = readConfig( + { GITHUB_TOKEN: "t", GITHUB_REPOSITORY: "o/r", DRY_RUN: "true", GRACE_PERIOD_DAYS: "0" }, + NOW, + ); + expect(parsed.dryRun).toBe(true); + expect(parsed.graceDays).toBe(0); + }); + + test("an empty GRACE_PERIOD_DAYS, as a schedule run renders it, means the default", () => { + const parsed = readConfig({ GITHUB_TOKEN: "t", GITHUB_REPOSITORY: "o/r", GRACE_PERIOD_DAYS: "" }, NOW); + expect(parsed.graceDays).toBe(3); + }); + + test("refuses a missing token, a malformed repository, or a bad grace period", () => { + expect(() => readConfig({ GITHUB_REPOSITORY: "o/r" }, NOW)).toThrow("GITHUB_TOKEN"); + expect(() => readConfig({ GITHUB_TOKEN: "t", GITHUB_REPOSITORY: "litellm" }, NOW)).toThrow("owner/repo"); + expect(() => readConfig({ GITHUB_TOKEN: "t", GITHUB_REPOSITORY: "o/r", GRACE_PERIOD_DAYS: "-1" }, NOW)).toThrow( + "GRACE_PERIOD_DAYS", + ); + }); +}); diff --git a/scripts/auto-close-duplicates.ts b/scripts/auto-close-duplicates.ts index 6e361af3e24..941f281efe6 100644 --- a/scripts/auto-close-duplicates.ts +++ b/scripts/auto-close-duplicates.ts @@ -1,306 +1,295 @@ #!/usr/bin/env bun -declare global { - var process: { - env: Record; - }; +declare const process: { readonly env: Readonly> }; + +export interface Issue { + readonly number: number; + readonly title: string; + readonly state: string; + readonly user: { readonly login: string }; + readonly closed_by?: { readonly type: string } | null; + readonly pull_request?: unknown; } -interface GitHubIssue { - number: number; - title: string; - labels: { name: string }[]; +export interface Comment { + readonly id: number; + readonly body: string; + readonly created_at: string; + readonly user: { readonly type: string; readonly login: string }; } -interface GitHubComment { - id: number; - body: string; - created_at: string; - user: { type: string }; +export interface Reaction { + readonly content: string; } -interface GitHubReaction { - content: string; +export interface GitHubApi { + readonly request: (method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: object) => Promise; } -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(); +export interface SweepConfig { + readonly repo: string; + readonly graceDays: number; + readonly dryRun: boolean; + readonly now: Date; } -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); +export type NoticeVerdict = + | { readonly kind: "pending"; readonly notice: Comment; readonly candidates: readonly number[] } + | { readonly kind: "skip"; readonly reason: string }; + +export type CloseVerdict = + | { readonly kind: "close"; readonly duplicateOf: number } + | { readonly kind: "skip"; readonly reason: string }; + +export type ReopenVerdict = + | { readonly kind: "reopen" } + | { readonly kind: "skip"; readonly reason: string }; + +export const FLAG_LABEL = "potential-duplicate"; +export const CLOSED_MARKER = ""; +export const DEFAULT_GRACE_DAYS = 3; +export const REOPEN_COMMENT = + "Reopened automatically: the reporter replied after the duplicate close, so this needs a human look."; +const NOTICE_MARKER = //; +const MIN_TITLE_WORDS = 3; +const PAGE_SIZE = 100; +const DAY_MS = 24 * 60 * 60 * 1000; +const REOPEN_LOOKBACK_DAYS = 30; + +const skip = (reason: string): { readonly kind: "skip"; readonly reason: string } => ({ kind: "skip", reason }); + +export function normalizeTitle(title: string): string { + return title + .toLowerCase() + .replace(/^\s*\[[^\]]*\]\s*:?/, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +export function candidateNumbers(noticeBody: string, issueNumber: number): readonly number[] { + const field = noticeBody.match(NOTICE_MARKER); if (!field) { - return null; + return []; } - - const candidates = field[1] + const older = 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; + .filter((candidate) => candidate < issueNumber); + return [...new Set(older)].sort((a, b) => a - b); } -async function closeIssueAsDuplicate( - owner: string, - repo: string, +export function pendingNotice( + issue: Issue, + comments: readonly Comment[], + config: Pick, +): NoticeVerdict { + if (issue.pull_request !== undefined) { + return skip("is a pull request"); + } + if (comments.some((comment) => comment.body.includes(CLOSED_MARKER))) { + return skip("was reopened after an automatic close"); + } + const notices = comments.filter((comment) => comment.user.type === "Bot" && NOTICE_MARKER.test(comment.body)); + const notice = notices[notices.length - 1]; + if (notice === undefined) { + return skip("carries no duplicate notice"); + } + const noticeAt = new Date(notice.created_at); + const ageDays = (config.now.getTime() - noticeAt.getTime()) / DAY_MS; + if (ageDays < config.graceDays) { + return skip(`notice is ${ageDays.toFixed(1)} days old, grace period is ${config.graceDays}`); + } + if (comments.some((comment) => comment.user.type !== "Bot" && new Date(comment.created_at) > noticeAt)) { + return skip("someone replied after the notice"); + } + const candidates = candidateNumbers(notice.body, issue.number); + if (candidates.length === 0) { + return skip("no candidate is older than this issue"); + } + return { kind: "pending", notice, candidates }; +} + +export function duplicateTarget( + issue: Issue, + candidates: readonly Issue[], + reactions: readonly Reaction[], +): CloseVerdict { + if (reactions.some((reaction) => reaction.content === "-1")) { + return skip("someone gave the notice a thumbs down"); + } + const title = normalizeTitle(issue.title); + if (title.split(" ").length < MIN_TITLE_WORDS) { + return skip(`title "${issue.title}" is too short to match on`); + } + const original = candidates.find( + (candidate) => + candidate.state === "open" && candidate.pull_request === undefined && normalizeTitle(candidate.title) === title, + ); + if (original === undefined) { + return skip("no older open issue has the identical title"); + } + return { kind: "close", duplicateOf: original.number }; +} + +export function reopenTarget(issue: Issue, comments: readonly Comment[]): ReopenVerdict { + if (issue.pull_request !== undefined) { + return skip("is a pull request"); + } + if (issue.closed_by?.type !== "Bot") { + return skip("was closed by a person"); + } + const marker = comments.find((comment) => comment.body.includes(CLOSED_MARKER)); + if (marker === undefined) { + return skip("carries no automatic-close marker"); + } + const markerAt = new Date(marker.created_at); + if (!comments.some((comment) => comment.user.login === issue.user.login && new Date(comment.created_at) > markerAt)) { + return skip("the reporter has not replied since the close"); + } + return { kind: "reopen" }; +} + +export function closingComment(duplicateOf: number, graceDays: number): string { + return `Closed automatically as a duplicate of #${duplicateOf}. Its title is identical to that older open issue and the duplicate notice above went unanswered for ${graceDays} days. If this is wrong, comment here with how it differs from #${duplicateOf} and this issue will be reopened automatically within a day. + +${CLOSED_MARKER}`; +} + +async function listAll(api: GitHubApi, path: string, page = 1): Promise { + const separator = path.includes("?") ? "&" : "?"; + const batch = await api.request("GET", `${path}${separator}per_page=${PAGE_SIZE}&page=${page}`); + return batch.length < PAGE_SIZE ? batch : [...batch, ...(await listAll(api, path, page + 1))]; +} + +async function closeAsDuplicate( + api: GitHubApi, + config: SweepConfig, issueNumber: number, - duplicateOfNumber: number, - token: string, + duplicateOf: number, ): Promise { - await githubRequest( - `/repos/${owner}/${repo}/issues/${issueNumber}/comments`, - token, - "POST", - { - body: `This issue has been automatically closed as a duplicate of #${duplicateOfNumber}. + const issuePath = `/repos/${config.repo}/issues/${issueNumber}`; + await api.request("POST", `${issuePath}/comments`, { body: closingComment(duplicateOf, config.graceDays) }); + await api.request("POST", `${issuePath}/labels`, { labels: ["duplicate"] }); + await api.request("PATCH", issuePath, { state: "closed", state_reason: "duplicate" }); +} -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}. +async function reopenForReporter(api: GitHubApi, config: SweepConfig, issueNumber: number): Promise { + const issuePath = `/repos/${config.repo}/issues/${issueNumber}`; + await api.request("DELETE", `${issuePath}/labels/duplicate`); + await api.request("PATCH", issuePath, { state: "open" }); + await api.request("POST", `${issuePath}/comments`, { body: REOPEN_COMMENT }); +} -`, +export async function sweepClosedIssue(api: GitHubApi, config: SweepConfig, issueNumber: number): Promise { + const issue = await api.request("GET", `/repos/${config.repo}/issues/${issueNumber}`); + const comments = await listAll(api, `/repos/${config.repo}/issues/${issueNumber}/comments`); + const verdict = reopenTarget(issue, comments); + if (verdict.kind === "reopen" && !config.dryRun) { + await reopenForReporter(api, config, issueNumber); + } + return verdict; +} + +export async function sweepIssue(api: GitHubApi, config: SweepConfig, issue: Issue): Promise { + const comments = await listAll(api, `/repos/${config.repo}/issues/${issue.number}/comments`); + const pending = pendingNotice(issue, comments, config); + if (pending.kind === "skip") { + return pending; + } + const reactions = await listAll(api, `/repos/${config.repo}/issues/comments/${pending.notice.id}/reactions`); + const candidates = await Promise.all( + pending.candidates.map((candidate) => api.request("GET", `/repos/${config.repo}/issues/${candidate}`)), + ); + const verdict = duplicateTarget(issue, candidates, reactions); + if (verdict.kind === "close" && !config.dryRun) { + await closeAsDuplicate(api, config, issue.number, verdict.duplicateOf); + } + return verdict; +} + +function describe(issue: Issue, verdict: CloseVerdict, dryRun: boolean): string { + if (verdict.kind === "skip") { + return `#${issue.number}: skipped, ${verdict.reason}`; + } + return `#${issue.number}: ${dryRun ? "would close" : "closed"} as a duplicate of #${verdict.duplicateOf}`; +} + +export async function sweep(api: GitHubApi, config: SweepConfig): Promise { + const issues = await listAll(api, `/repos/${config.repo}/issues?state=open&labels=${FLAG_LABEL}`); + console.log(`${issues.length} open issues carry the ${FLAG_LABEL} label in ${config.repo}${config.dryRun ? " (dry run)" : ""}`); + return issues.reduce>(async (previous, issue) => { + const verdicts = await previous; + const verdict = await sweepIssue(api, config, issue); + console.log(describe(issue, verdict, config.dryRun)); + return [...verdicts, verdict]; + }, Promise.resolve([])); +} + +function describeReopen(issueNumber: number, verdict: ReopenVerdict, dryRun: boolean): string { + if (verdict.kind === "skip") { + return `#${issueNumber}: skipped, ${verdict.reason}`; + } + return `#${issueNumber}: ${dryRun ? "would reopen" : "reopened"} for the reporter's reply`; +} + +export async function reopenSweep(api: GitHubApi, config: SweepConfig): Promise { + const since = new Date(config.now.getTime() - REOPEN_LOOKBACK_DAYS * DAY_MS).toISOString(); + const closedPath = `/repos/${config.repo}/issues?state=closed&labels=duplicate,${FLAG_LABEL}&since=${encodeURIComponent(since)}`; + const issues = await listAll(api, closedPath); + console.log(`${issues.length} recently closed issues carry the duplicate and ${FLAG_LABEL} labels in ${config.repo}${config.dryRun ? " (dry run)" : ""}`); + return issues.reduce>(async (previous, issue) => { + const verdicts = await previous; + const verdict = await sweepClosedIssue(api, config, issue.number); + console.log(describeReopen(issue.number, verdict, config.dryRun)); + return [...verdicts, verdict]; + }, Promise.resolve([])); +} + +export function readConfig(env: Readonly>, now: Date): SweepConfig & { readonly token: string } { + const token = env.GITHUB_TOKEN; + const repo = env.GITHUB_REPOSITORY; + if (!token || !repo || !/^[\w.-]+\/[\w.-]+$/.test(repo)) { + throw new Error("GITHUB_TOKEN and GITHUB_REPOSITORY (owner/repo) are required"); + } + const rawGraceDays = env.GRACE_PERIOD_DAYS?.trim(); + const graceDays = rawGraceDays === undefined || rawGraceDays === "" ? DEFAULT_GRACE_DAYS : Number(rawGraceDays); + if (!Number.isFinite(graceDays) || graceDays < 0) { + throw new Error(`GRACE_PERIOD_DAYS must be a non-negative number, got "${env.GRACE_PERIOD_DAYS}"`); + } + return { token, repo, graceDays, dryRun: env.DRY_RUN === "true", now }; +} + +export function githubApi(token: string): GitHubApi { + return { + request: async (method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: object): Promise => { + const response = await fetch(`https://api.github.com${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "litellm-auto-close-duplicates", + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`${method} ${path} failed: ${response.status} ${response.statusText}`); + } + return (await response.json()) as T; }, - ); - - // 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); +if (import.meta.main) { + const { token, ...config } = readConfig(process.env, new Date()); + const api = githubApi(token); + const closeVerdicts = await sweep(api, config); + const reopenVerdicts = await reopenSweep(api, config); + const closed = closeVerdicts.filter((verdict) => verdict.kind === "close").length; + const reopened = reopenVerdicts.filter((verdict) => verdict.kind === "reopen").length; 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`, - ); - - // Any thumbs down, not just the author's. The notice tells every reader that a - // 👎 keeps the issue open, and anyone can already stop the clock by commenting, - // so honouring only the author would make the notice a lie without buying safety. - const thumbsDown = reactions.some((reaction) => reaction.content === "-1"); - console.log( - `[DEBUG] Issue #${issue.number} - thumbs down reaction: ${thumbsDown}`, - ); - - if (thumbsDown) { - console.log( - `[DEBUG] Issue #${issue.number} - someone 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`, + `${config.dryRun ? "Would close" : "Closed"} ${closed} of ${closeVerdicts.length} flagged issues, ${config.dryRun ? "would reopen" : "reopened"} ${reopened}`, ); } - -autoCloseDuplicates().catch(console.error); - -// Make it a module -export {}; From ed5761daef4ae17152446d182c860630c38b7268 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:28:47 -0700 Subject: [PATCH 7/7] fix(ci): keep earlier objections when the duplicate notice is re-posted The detector fires on issue edits and posts a fresh notice each time, so the sweep now counts replies from the first notice on and a thumbs down on any notice --- scripts/auto-close-duplicates.test.ts | 29 +++++++++++++++++++++++---- scripts/auto-close-duplicates.ts | 23 ++++++++++++--------- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/scripts/auto-close-duplicates.test.ts b/scripts/auto-close-duplicates.test.ts index 6a7a3a507bd..b49bf05cbc2 100644 --- a/scripts/auto-close-duplicates.test.ts +++ b/scripts/auto-close-duplicates.test.ts @@ -14,6 +14,7 @@ import { type Comment, type GitHubApi, type Issue, + type Reaction, type SweepConfig, } from "./auto-close-duplicates"; @@ -78,6 +79,15 @@ describe("pendingNotice", () => { expect(reposted.kind).toBe("skip"); }); + test("an objection posted before a re-posted notice still keeps the issue open", () => { + const verdict = pendingNotice( + issue(35, "t"), + [notice([10], daysAgo(10)), humanComment(daysAgo(7)), notice([10], daysAgo(4), { id: 902 })], + config, + ); + expect(verdict).toEqual({ kind: "skip", reason: "someone replied after the notice" }); + }); + test("a zero-day grace period acts on the notice at once", () => { const verdict = pendingNotice(issue(35, "t"), [notice([10], daysAgo(0.01))], { ...config, graceDays: 0 }); expect(verdict.kind).toBe("pending"); @@ -155,7 +165,10 @@ describe("sweepIssue", () => { const reporter = issue(35, "[Bug]: Gemma 4-e4b fails on Vertex"); const original = issue(10, "[Bug]: Gemma 4-e4b fails on Vertex"); - function fakeApi(): { readonly api: GitHubApi; readonly writes: readonly string[] } { + function fakeApi( + comments: readonly Comment[] = [notice([10], daysAgo(5))], + reactionsByNotice: Readonly> = {}, + ): { readonly api: GitHubApi; readonly writes: readonly string[] } { const writes: string[] = []; const api: GitHubApi = { request: async (method: string, path: string, body?: object): Promise => { @@ -164,10 +177,11 @@ describe("sweepIssue", () => { return {} as T; } if (path.startsWith("/repos/BerriAI/litellm/issues/35/comments")) { - return [notice([10], daysAgo(5))] as T; + return comments as T; } - if (path.startsWith("/repos/BerriAI/litellm/issues/comments/900/reactions")) { - return [] as T; + const reactionsPath = path.match(/^\/repos\/BerriAI\/litellm\/issues\/comments\/(\d+)\/reactions/); + if (reactionsPath) { + return (reactionsByNotice[Number(reactionsPath[1])] ?? []) as T; } if (path === "/repos/BerriAI/litellm/issues/10") { return original as T; @@ -185,6 +199,13 @@ describe("sweepIssue", () => { expect(writes).toEqual([]); }); + test("a thumbs down on an earlier notice still keeps the issue open", async () => { + const { api, writes } = fakeApi([notice([10], daysAgo(9)), notice([10], daysAgo(5), { id: 902 })], { 900: [{ content: "-1" }] }); + const verdict = await sweepIssue(api, config, reporter); + expect(verdict).toEqual({ kind: "skip", reason: "someone gave the notice a thumbs down" }); + expect(writes).toEqual([]); + }); + test("a real run comments, labels, then closes with the duplicate reason", async () => { const { api, writes } = fakeApi(); const verdict = await sweepIssue(api, config, reporter); diff --git a/scripts/auto-close-duplicates.ts b/scripts/auto-close-duplicates.ts index 941f281efe6..c595104d886 100644 --- a/scripts/auto-close-duplicates.ts +++ b/scripts/auto-close-duplicates.ts @@ -34,7 +34,7 @@ export interface SweepConfig { } export type NoticeVerdict = - | { readonly kind: "pending"; readonly notice: Comment; readonly candidates: readonly number[] } + | { readonly kind: "pending"; readonly notices: readonly Comment[]; readonly candidates: readonly number[] } | { readonly kind: "skip"; readonly reason: string }; export type CloseVerdict = @@ -91,23 +91,24 @@ export function pendingNotice( return skip("was reopened after an automatic close"); } const notices = comments.filter((comment) => comment.user.type === "Bot" && NOTICE_MARKER.test(comment.body)); - const notice = notices[notices.length - 1]; - if (notice === undefined) { + const first = notices[0]; + const latest = notices[notices.length - 1]; + if (first === undefined || latest === undefined) { return skip("carries no duplicate notice"); } - const noticeAt = new Date(notice.created_at); - const ageDays = (config.now.getTime() - noticeAt.getTime()) / DAY_MS; + const ageDays = (config.now.getTime() - new Date(latest.created_at).getTime()) / DAY_MS; if (ageDays < config.graceDays) { return skip(`notice is ${ageDays.toFixed(1)} days old, grace period is ${config.graceDays}`); } - if (comments.some((comment) => comment.user.type !== "Bot" && new Date(comment.created_at) > noticeAt)) { + const firstNoticeAt = new Date(first.created_at); + if (comments.some((comment) => comment.user.type !== "Bot" && new Date(comment.created_at) > firstNoticeAt)) { return skip("someone replied after the notice"); } - const candidates = candidateNumbers(notice.body, issue.number); + const candidates = candidateNumbers(latest.body, issue.number); if (candidates.length === 0) { return skip("no candidate is older than this issue"); } - return { kind: "pending", notice, candidates }; + return { kind: "pending", notices, candidates }; } export function duplicateTarget( @@ -197,7 +198,11 @@ export async function sweepIssue(api: GitHubApi, config: SweepConfig, issue: Iss if (pending.kind === "skip") { return pending; } - const reactions = await listAll(api, `/repos/${config.repo}/issues/comments/${pending.notice.id}/reactions`); + const reactions = ( + await Promise.all( + pending.notices.map((notice) => listAll(api, `/repos/${config.repo}/issues/comments/${notice.id}/reactions`)), + ) + ).flat(); const candidates = await Promise.all( pending.candidates.map((candidate) => api.request("GET", `/repos/${config.repo}/issues/${candidate}`)), );