Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 277 additions & 0 deletions scripts/auto-close-duplicates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
#!/usr/bin/env bun

declare global {
var process: {
env: Record<string, string | undefined>;
};
}

interface GitHubIssue {
number: number;
title: string;
user: { id: number };
created_at: string;
}

interface GitHubComment {
id: number;
body: string;
created_at: string;
user: { type: string; id: number };
}

interface GitHubReaction {
user: { id: number };
content: string;
}

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): number | null {
let match = commentBody.match(/#(\d+)/);
if (match) {
return parseInt(match[1], 10);
}

match = commentBody.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/);
if (match) {
return parseInt(match[1], 10);
}

return null;
}

async function closeIssueAsDuplicate(
owner: string,
repo: string,
issueNumber: number,
duplicateOfNumber: number,
token: string,
): Promise<void> {
await githubRequest(
`/repos/${owner}/${repo}/issues/${issueNumber}`,
token,
"PATCH",
{
state: "closed",
state_reason: "duplicate",
labels: ["duplicate"],
},
);

await githubRequest(
`/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
token,
"POST",
{
body: `This issue has been automatically closed as a duplicate of #${duplicateOfNumber}.

If this is incorrect, please re-open this issue or create a new one.`,
},
);
}

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 || "deepgram";
const repo = process.env.GITHUB_REPOSITORY_NAME || "cli";
console.log(`[DEBUG] Repository: ${owner}/${repo}`);

const threeDaysAgo = new Date();
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
console.log(
`[DEBUG] Checking for duplicate comments older than: ${threeDaysAgo.toISOString()}`,
);

console.log("[DEBUG] Fetching open issues created more than 3 days ago...");
const allIssues: GitHubIssue[] = [];
let page = 1;
const perPage = 100;

while (true) {
const pageIssues: GitHubIssue[] = await githubRequest(
`/repos/${owner}/${repo}/issues?state=open&per_page=${perPage}&page=${page}`,
token,
);

if (pageIssues.length === 0) break;

const oldEnoughIssues = pageIssues.filter(
(issue) => new Date(issue.created_at) <= threeDaysAgo,
);

allIssues.push(...oldEnoughIssues);
page++;

if (page > 20) break;
}

const issues = allIssues;
console.log(`[DEBUG] Found ${issues.length} open 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`,
token,
);
console.log(
`[DEBUG] Issue #${issue.number} has ${comments.length} comments`,
);

const dupeComments = comments.filter(
(comment) =>
comment.body.includes("Found") &&
comment.body.includes("possible duplicate") &&
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`,
token,
);
console.log(
`[DEBUG] Issue #${issue.number} - duplicate comment has ${reactions.length} reactions`,
);

const authorThumbsDown = reactions.some(
(reaction) =>
reaction.user.id === issue.user.id && 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,
);
if (!duplicateIssueNumber) {
console.log(
`[DEBUG] Issue #${issue.number} - could not extract duplicate issue number from comment, 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);

export {};
Loading
Loading