mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
parent
af340c0240
commit
3ea11b64e6
3 changed files with 341 additions and 151 deletions
33
.github/workflows/auto-close-duplicates.yml
vendored
Normal file
33
.github/workflows/auto-close-duplicates.yml
vendored
Normal 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 }}
|
||||
151
.github/workflows/close_stale_duplicate_issues.yml
vendored
151
.github/workflows/close_stale_duplicate_issues.yml
vendored
|
|
@ -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}`);
|
||||
308
scripts/auto-close-duplicates.ts
Normal file
308
scripts/auto-close-duplicates.ts
Normal 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 {};
|
||||
Loading…
Add table
Reference in a new issue