05dde7414f
- Skip closing an issue as duplicate of another that is already closed (avoids circular closure when bot and human close in opposite order). - Skip when duplicate target is self (same issue number). - Extract testable helpers: isDupeComment, isDupeCommentOldEnough, authorDisagreedWithDupe, getLastDupeComment, decideAutoClose. - Add 23 unit tests (Bun) and run them in CI before auto-close step. - Add scripts/AUTO_CLOSE_DUPLICATES_CROSS_VERIFY.md for impact summary. Co-authored-by: Cursor <cursoragent@cursor.com>
342 lines
9.2 KiB
TypeScript
342 lines
9.2 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
declare global {
|
|
var process: {
|
|
env: Record<string, string | undefined>;
|
|
};
|
|
}
|
|
|
|
export interface GitHubIssue {
|
|
number: number;
|
|
title: string;
|
|
state: string;
|
|
user: { id: number };
|
|
created_at: string;
|
|
}
|
|
|
|
export interface GitHubComment {
|
|
id: number;
|
|
body: string;
|
|
created_at: string;
|
|
user: { type: string; id: number };
|
|
}
|
|
|
|
export interface GitHubReaction {
|
|
user: { id: number };
|
|
content: string;
|
|
}
|
|
|
|
async function githubRequest<T>(
|
|
endpoint: string,
|
|
token: string,
|
|
method: string = "GET",
|
|
body?: unknown
|
|
): Promise<T> {
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${token}`,
|
|
Accept: "application/vnd.github.v3+json",
|
|
"User-Agent": "auto-close-duplicates-script",
|
|
};
|
|
|
|
if (body) {
|
|
headers["Content-Type"] = "application/json";
|
|
}
|
|
|
|
const options: RequestInit = { method, headers };
|
|
if (body) {
|
|
options.body = JSON.stringify(body);
|
|
}
|
|
|
|
const response = await fetch(`https://api.github.com${endpoint}`, options);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`GitHub API request failed: ${response.status} ${response.statusText}`
|
|
);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
/** True if comment is a bot "possible duplicate" detection (used for filtering). */
|
|
export function isDupeComment(comment: GitHubComment): boolean {
|
|
const bodyLower = comment.body.toLowerCase();
|
|
return (
|
|
bodyLower.includes("possible duplicate") && comment.user.type === "Bot"
|
|
);
|
|
}
|
|
|
|
/** True if the duplicate comment is old enough to auto-close (>= 12h). */
|
|
export function isDupeCommentOldEnough(
|
|
dupeCommentDate: Date,
|
|
twelveHoursAgo: Date
|
|
): boolean {
|
|
return dupeCommentDate <= twelveHoursAgo;
|
|
}
|
|
|
|
/** True if the issue author reacted with thumbs down to the duplicate comment. */
|
|
export function authorDisagreedWithDupe(
|
|
reactions: GitHubReaction[],
|
|
issue: GitHubIssue
|
|
): boolean {
|
|
return reactions.some(
|
|
(r) => r.user.id === issue.user.id && r.content === "-1"
|
|
);
|
|
}
|
|
|
|
/** Returns the most recent duplicate-detection comment, or null if none. */
|
|
export function getLastDupeComment(
|
|
comments: GitHubComment[]
|
|
): GitHubComment | null {
|
|
const dupeComments = comments.filter(isDupeComment);
|
|
return dupeComments.length > 0 ? dupeComments[dupeComments.length - 1]! : null;
|
|
}
|
|
|
|
export function extractDuplicateIssueNumber(commentBody: string): number | null {
|
|
// Try to match #123 format first
|
|
let match = commentBody.match(/#(\d+)/);
|
|
if (match) {
|
|
return parseInt(match[1], 10);
|
|
}
|
|
|
|
// Try to match GitHub issue URL format: https://github.com/owner/repo/issues/123
|
|
match = commentBody.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/);
|
|
if (match) {
|
|
return parseInt(match[1], 10);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Decides whether to auto-close this issue as duplicate of another.
|
|
* Returns the target issue number to close as duplicate of, or null to skip.
|
|
* Used by the main loop and by tests.
|
|
*/
|
|
export async function decideAutoClose(
|
|
issue: GitHubIssue,
|
|
lastDupeComment: GitHubComment,
|
|
getTargetIssue: (issueNumber: number) => Promise<{ state: string } | null>
|
|
): Promise<number | null> {
|
|
const duplicateIssueNumber = extractDuplicateIssueNumber(lastDupeComment.body);
|
|
if (duplicateIssueNumber === null) return null;
|
|
|
|
if (duplicateIssueNumber === issue.number) return null;
|
|
|
|
try {
|
|
const targetIssue = await getTargetIssue(duplicateIssueNumber);
|
|
if (!targetIssue || targetIssue.state !== "open") return null;
|
|
return duplicateIssueNumber;
|
|
} catch {
|
|
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;
|
|
const repo = process.env.GITHUB_REPOSITORY_NAME;
|
|
if (!owner || !repo) {
|
|
throw new Error(
|
|
"GITHUB_REPOSITORY_OWNER and GITHUB_REPOSITORY_NAME environment variables are required"
|
|
);
|
|
}
|
|
console.log(`[DEBUG] Repository: ${owner}/${repo}`);
|
|
|
|
const twelveHoursAgo = new Date();
|
|
twelveHoursAgo.setTime(twelveHoursAgo.getTime() - 12 * 60 * 60 * 1000);
|
|
console.log(
|
|
`[DEBUG] Checking for duplicate comments older than: ${twelveHoursAgo.toISOString()}`
|
|
);
|
|
|
|
console.log("[DEBUG] Fetching open issues created more than 12 hours 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;
|
|
|
|
// Filter for issues created more than 12 hours ago
|
|
const oldEnoughIssues = pageIssues.filter(
|
|
(issue) => new Date(issue.created_at) <= twelveHoursAgo
|
|
);
|
|
|
|
allIssues.push(...oldEnoughIssues);
|
|
page++;
|
|
|
|
// Safety limit to avoid infinite loops
|
|
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 lastDupeComment = getLastDupeComment(comments);
|
|
const dupeCount = comments.filter(isDupeComment).length;
|
|
console.log(
|
|
`[DEBUG] Issue #${issue.number} has ${dupeCount} duplicate detection comments`
|
|
);
|
|
|
|
if (lastDupeComment === null) {
|
|
console.log(
|
|
`[DEBUG] Issue #${issue.number} - no duplicate comments found, skipping`
|
|
);
|
|
continue;
|
|
}
|
|
const dupeCommentDate = new Date(lastDupeComment.created_at);
|
|
console.log(
|
|
`[DEBUG] Issue #${
|
|
issue.number
|
|
} - most recent duplicate comment from: ${dupeCommentDate.toISOString()}`
|
|
);
|
|
|
|
if (!isDupeCommentOldEnough(dupeCommentDate, twelveHoursAgo)) {
|
|
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)
|
|
)} hours)`
|
|
);
|
|
|
|
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 = authorDisagreedWithDupe(reactions, issue);
|
|
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 duplicateOf = await decideAutoClose(
|
|
issue,
|
|
lastDupeComment,
|
|
(issueNumber) =>
|
|
githubRequest<GitHubIssue>(
|
|
`/repos/${owner}/${repo}/issues/${issueNumber}`,
|
|
token
|
|
).then((i) => ({ state: i.state }))
|
|
);
|
|
|
|
if (duplicateOf === null) {
|
|
console.log(
|
|
`[DEBUG] Issue #${issue.number} - skipping (invalid/self/closed target or fetch error)`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
candidateCount++;
|
|
const issueUrl = `https://github.com/${owner}/${repo}/issues/${issue.number}`;
|
|
|
|
try {
|
|
console.log(
|
|
`[INFO] Auto-closing issue #${issue.number} as duplicate of #${duplicateOf}: ${issueUrl}`
|
|
);
|
|
await closeIssueAsDuplicate(
|
|
owner,
|
|
repo,
|
|
issue.number,
|
|
duplicateOf,
|
|
token
|
|
);
|
|
console.log(
|
|
`[SUCCESS] Successfully closed issue #${issue.number} as duplicate of #${duplicateOf}`
|
|
);
|
|
} 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`
|
|
);
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
autoCloseDuplicates().catch(console.error);
|
|
}
|
|
|
|
export {};
|