spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

claude-code / scripts / auto-close-duplicates.ts
263 lines · 7.3 KBRaw
1#!/usr/bin/env bun
2
3declare global {
4 var process: {
5 env: Record<string, string | undefined>;
6 };
7}
8
9interface GitHubIssue {
10 number: number;
11 title: string;
12 user: { id: number };
13 created_at: string;
14}
15
16interface GitHubComment {
17 id: number;
18 body: string;
19 created_at: string;
20 user: { type: string; id: number };
21}
22
23interface GitHubReaction {
24 user: { id: number };
25 content: string;
26}
27
28async function githubRequest<T>(endpoint: string, token: string, method: string = 'GET', body?: any): Promise<T> {
29 const response = await fetch(`https://api.github.com${endpoint}`, {
30 method,
31 headers: {
32 Authorization: `Bearer ${token}`,
33 Accept: "application/vnd.github.v3+json",
34 "User-Agent": "auto-close-duplicates-script",
35 ...(body && { "Content-Type": "application/json" }),
36 },
37 ...(body && { body: JSON.stringify(body) }),
38 });
39
40 if (!response.ok) {
41 throw new Error(
42 `GitHub API request failed: ${response.status} ${response.statusText}`
43 );
44 }
45
46 return response.json();
47}
48
49function extractDuplicateIssueNumber(commentBody: string): number | null {
50 const match = commentBody.match(/#(\d+)/);
51 return match ? parseInt(match[1], 10) : null;
52}
53
54async function closeIssueAsDuplicate(
55 owner: string,
56 repo: string,
57 issueNumber: number,
58 duplicateOfNumber: number,
59 token: string
60): Promise<void> {
61 await githubRequest(
62 `/repos/${owner}/${repo}/issues/${issueNumber}`,
63 token,
64 'PATCH',
65 {
66 state: 'closed',
67 state_reason: 'not_planned'
68 }
69 );
70
71 await githubRequest(
72 `/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
73 token,
74 'POST',
75 {
76 body: `This issue has been automatically closed as a duplicate of #${duplicateOfNumber}.
77
78If this is incorrect, please re-open this issue or create a new one.
79
80🤖 Generated with [Claude Code](https://claude.ai/code)`
81 }
82 );
83}
84
85async function autoCloseDuplicates(): Promise<void> {
86 console.log("[DEBUG] Starting auto-close duplicates script");
87
88 const token = process.env.GITHUB_TOKEN;
89 if (!token) {
90 throw new Error("GITHUB_TOKEN environment variable is required");
91 }
92 console.log("[DEBUG] GitHub token found");
93
94 const owner = process.env.GITHUB_REPOSITORY_OWNER || "anthropics";
95 const repo = process.env.GITHUB_REPOSITORY_NAME || "claude-code";
96 console.log(`[DEBUG] Repository: ${owner}/${repo}`);
97
98 const threeDaysAgo = new Date();
99 threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
100 console.log(
101 `[DEBUG] Checking for duplicate comments older than: ${threeDaysAgo.toISOString()}`
102 );
103
104 console.log("[DEBUG] Fetching open issues created more than 3 days ago...");
105 const allIssues: GitHubIssue[] = [];
106 let page = 1;
107 const perPage = 100;
108
109 while (true) {
110 const pageIssues: GitHubIssue[] = await githubRequest(
111 `/repos/${owner}/${repo}/issues?state=open&per_page=${perPage}&page=${page}`,
112 token
113 );
114
115 if (pageIssues.length === 0) break;
116
117 // Filter for issues created more than 3 days ago
118 const oldEnoughIssues = pageIssues.filter(issue =>
119 new Date(issue.created_at) <= threeDaysAgo
120 );
121
122 allIssues.push(...oldEnoughIssues);
123 page++;
124
125 // Safety limit to avoid infinite loops
126 if (page > 20) break;
127 }
128
129 const issues = allIssues;
130 console.log(`[DEBUG] Found ${issues.length} open issues`);
131
132 let processedCount = 0;
133 let candidateCount = 0;
134
135 for (const issue of issues) {
136 processedCount++;
137 console.log(
138 `[DEBUG] Processing issue #${issue.number} (${processedCount}/${issues.length}): ${issue.title}`
139 );
140
141 console.log(`[DEBUG] Fetching comments for issue #${issue.number}...`);
142 const comments: GitHubComment[] = await githubRequest(
143 `/repos/${owner}/${repo}/issues/${issue.number}/comments`,
144 token
145 );
146 console.log(
147 `[DEBUG] Issue #${issue.number} has ${comments.length} comments`
148 );
149
150 const dupeComments = comments.filter(
151 (comment) =>
152 comment.body.includes("Found") &&
153 comment.body.includes("possible duplicate") &&
154 comment.user.type === "Bot"
155 );
156 console.log(
157 `[DEBUG] Issue #${issue.number} has ${dupeComments.length} duplicate detection comments`
158 );
159
160 if (dupeComments.length === 0) {
161 console.log(
162 `[DEBUG] Issue #${issue.number} - no duplicate comments found, skipping`
163 );
164 continue;
165 }
166
167 const lastDupeComment = dupeComments[dupeComments.length - 1];
168 const dupeCommentDate = new Date(lastDupeComment.created_at);
169 console.log(
170 `[DEBUG] Issue #${
171 issue.number
172 } - most recent duplicate comment from: ${dupeCommentDate.toISOString()}`
173 );
174
175 if (dupeCommentDate > threeDaysAgo) {
176 console.log(
177 `[DEBUG] Issue #${issue.number} - duplicate comment is too recent, skipping`
178 );
179 continue;
180 }
181 console.log(
182 `[DEBUG] Issue #${
183 issue.number
184 } - duplicate comment is old enough (${Math.floor(
185 (Date.now() - dupeCommentDate.getTime()) / (1000 * 60 * 60 * 24)
186 )} days)`
187 );
188
189 const commentsAfterDupe = comments.filter(
190 (comment) => new Date(comment.created_at) > dupeCommentDate
191 );
192 console.log(
193 `[DEBUG] Issue #${issue.number} - ${commentsAfterDupe.length} comments after duplicate detection`
194 );
195
196 if (commentsAfterDupe.length > 0) {
197 console.log(
198 `[DEBUG] Issue #${issue.number} - has activity after duplicate comment, skipping`
199 );
200 continue;
201 }
202
203 console.log(
204 `[DEBUG] Issue #${issue.number} - checking reactions on duplicate comment...`
205 );
206 const reactions: GitHubReaction[] = await githubRequest(
207 `/repos/${owner}/${repo}/issues/comments/${lastDupeComment.id}/reactions`,
208 token
209 );
210 console.log(
211 `[DEBUG] Issue #${issue.number} - duplicate comment has ${reactions.length} reactions`
212 );
213
214 const authorThumbsDown = reactions.some(
215 (reaction) =>
216 reaction.user.id === issue.user.id && reaction.content === "-1"
217 );
218 console.log(
219 `[DEBUG] Issue #${issue.number} - author thumbs down reaction: ${authorThumbsDown}`
220 );
221
222 if (authorThumbsDown) {
223 console.log(
224 `[DEBUG] Issue #${issue.number} - author disagreed with duplicate detection, skipping`
225 );
226 continue;
227 }
228
229 const duplicateIssueNumber = extractDuplicateIssueNumber(lastDupeComment.body);
230 if (!duplicateIssueNumber) {
231 console.log(
232 `[DEBUG] Issue #${issue.number} - could not extract duplicate issue number from comment, skipping`
233 );
234 continue;
235 }
236
237 candidateCount++;
238 const issueUrl = `https://github.com/${owner}/${repo}/issues/${issue.number}`;
239
240 try {
241 console.log(
242 `[INFO] Auto-closing issue #${issue.number} as duplicate of #${duplicateIssueNumber}: ${issueUrl}`
243 );
244 await closeIssueAsDuplicate(owner, repo, issue.number, duplicateIssueNumber, token);
245 console.log(
246 `[SUCCESS] Successfully closed issue #${issue.number} as duplicate of #${duplicateIssueNumber}`
247 );
248 } catch (error) {
249 console.error(
250 `[ERROR] Failed to close issue #${issue.number} as duplicate: ${error}`
251 );
252 }
253 }
254
255 console.log(
256 `[DEBUG] Script completed. Processed ${processedCount} issues, found ${candidateCount} candidates for auto-close`
257 );
258}
259
260autoCloseDuplicates().catch(console.error);
261
262// Make it a module
263export {};