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.
This commit is contained in:
mubashir1osmani 2026-08-26 15:18:06 -04:00
parent af340c0240
commit 3ea11b64e6
3 changed files with 341 additions and 151 deletions

View file

@ -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 }}

View file

@ -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 = '<!-- litellm:potential-duplicate';
const CANDIDATES = /<!-- litellm:potential-duplicate candidates=([\d,]*) -->/;
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<!-- litellm:closed-as-duplicate -->`,
});
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}`);

View file

@ -0,0 +1,308 @@
#!/usr/bin/env bun
declare global {
var process: {
env: Record<string, string | undefined>;
};
}
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 = "<!-- litellm:potential-duplicate";
const CANDIDATES = /<!-- litellm:potential-duplicate candidates=([\d,]*) -->/;
const GRACE_PERIOD_DAYS = 3;
async function githubRequest<T>(
endpoint: string,
token: string,
method: string = "GET",
body?: any,
): Promise<T> {
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<void> {
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}.
<!-- litellm:closed-as-duplicate -->`,
},
);
// 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<void> {
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 {};