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
307 lines · 8.5 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 logStatsigEvent(eventName: string, value: number, metadata: Record<string, any>): Promise<void> {
55 const statsigApiKey = process.env.STATSIG_API_KEY;
56 if (!statsigApiKey) {
57 console.log("[DEBUG] STATSIG_API_KEY not found, skipping Statsig logging");
58 return;
59 }
60
61 const eventPayload = {
62 events: [{
63 eventName,
64 value,
65 metadata,
66 time: Math.floor(Date.now()).toString()
67 }]
68 };
69
70 try {
71 const response = await fetch('https://events.statsigapi.net/v1/log_event', {
72 method: 'POST',
73 headers: {
74 'Content-Type': 'application/json',
75 'STATSIG-API-KEY': statsigApiKey
76 },
77 body: JSON.stringify(eventPayload)
78 });
79
80 if (response.ok) {
81 console.log(`[DEBUG] Successfully logged Statsig event: ${eventName}`);
82 } else {
83 console.log(`[DEBUG] Failed to log Statsig event: ${response.status} ${response.statusText}`);
84 }
85 } catch (error) {
86 console.log(`[DEBUG] Error logging to Statsig: ${error}`);
87 }
88}
89
90async function closeIssueAsDuplicate(
91 owner: string,
92 repo: string,
93 issueNumber: number,
94 duplicateOfNumber: number,
95 token: string
96): Promise<void> {
97 await githubRequest(
98 `/repos/${owner}/${repo}/issues/${issueNumber}`,
99 token,
100 'PATCH',
101 {
102 state: 'closed',
103 state_reason: 'not_planned'
104 }
105 );
106
107 await githubRequest(
108 `/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
109 token,
110 'POST',
111 {
112 body: `This issue has been automatically closed as a duplicate of #${duplicateOfNumber}.
113
114If this is incorrect, please re-open this issue or create a new one.
115
116🤖 Generated with [Claude Code](https://claude.ai/code)`
117 }
118 );
119
120 // Log to Statsig
121 await logStatsigEvent('github_issue_closed_as_duplicate', 1, {
122 repository: `${owner}/${repo}`,
123 issue_number: issueNumber,
124 duplicate_of_issue: duplicateOfNumber,
125 closed_by: 'auto-close-script'
126 });
127}
128
129async function autoCloseDuplicates(): Promise<void> {
130 console.log("[DEBUG] Starting auto-close duplicates script");
131
132 const token = process.env.GITHUB_TOKEN;
133 if (!token) {
134 throw new Error("GITHUB_TOKEN environment variable is required");
135 }
136 console.log("[DEBUG] GitHub token found");
137
138 const owner = process.env.GITHUB_REPOSITORY_OWNER || "anthropics";
139 const repo = process.env.GITHUB_REPOSITORY_NAME || "claude-code";
140 console.log(`[DEBUG] Repository: ${owner}/${repo}`);
141
142 const threeDaysAgo = new Date();
143 threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
144 console.log(
145 `[DEBUG] Checking for duplicate comments older than: ${threeDaysAgo.toISOString()}`
146 );
147
148 console.log("[DEBUG] Fetching open issues created more than 3 days ago...");
149 const allIssues: GitHubIssue[] = [];
150 let page = 1;
151 const perPage = 100;
152
153 while (true) {
154 const pageIssues: GitHubIssue[] = await githubRequest(
155 `/repos/${owner}/${repo}/issues?state=open&per_page=${perPage}&page=${page}`,
156 token
157 );
158
159 if (pageIssues.length === 0) break;
160
161 // Filter for issues created more than 3 days ago
162 const oldEnoughIssues = pageIssues.filter(issue =>
163 new Date(issue.created_at) <= threeDaysAgo
164 );
165
166 allIssues.push(...oldEnoughIssues);
167 page++;
168
169 // Safety limit to avoid infinite loops
170 if (page > 20) break;
171 }
172
173 const issues = allIssues;
174 console.log(`[DEBUG] Found ${issues.length} open issues`);
175
176 let processedCount = 0;
177 let candidateCount = 0;
178
179 for (const issue of issues) {
180 processedCount++;
181 console.log(
182 `[DEBUG] Processing issue #${issue.number} (${processedCount}/${issues.length}): ${issue.title}`
183 );
184
185 console.log(`[DEBUG] Fetching comments for issue #${issue.number}...`);
186 const comments: GitHubComment[] = await githubRequest(
187 `/repos/${owner}/${repo}/issues/${issue.number}/comments`,
188 token
189 );
190 console.log(
191 `[DEBUG] Issue #${issue.number} has ${comments.length} comments`
192 );
193
194 const dupeComments = comments.filter(
195 (comment) =>
196 comment.body.includes("Found") &&
197 comment.body.includes("possible duplicate") &&
198 comment.user.type === "Bot"
199 );
200 console.log(
201 `[DEBUG] Issue #${issue.number} has ${dupeComments.length} duplicate detection comments`
202 );
203
204 if (dupeComments.length === 0) {
205 console.log(
206 `[DEBUG] Issue #${issue.number} - no duplicate comments found, skipping`
207 );
208 continue;
209 }
210
211 const lastDupeComment = dupeComments[dupeComments.length - 1];
212 const dupeCommentDate = new Date(lastDupeComment.created_at);
213 console.log(
214 `[DEBUG] Issue #${
215 issue.number
216 } - most recent duplicate comment from: ${dupeCommentDate.toISOString()}`
217 );
218
219 if (dupeCommentDate > threeDaysAgo) {
220 console.log(
221 `[DEBUG] Issue #${issue.number} - duplicate comment is too recent, skipping`
222 );
223 continue;
224 }
225 console.log(
226 `[DEBUG] Issue #${
227 issue.number
228 } - duplicate comment is old enough (${Math.floor(
229 (Date.now() - dupeCommentDate.getTime()) / (1000 * 60 * 60 * 24)
230 )} days)`
231 );
232
233 const commentsAfterDupe = comments.filter(
234 (comment) => new Date(comment.created_at) > dupeCommentDate
235 );
236 console.log(
237 `[DEBUG] Issue #${issue.number} - ${commentsAfterDupe.length} comments after duplicate detection`
238 );
239
240 if (commentsAfterDupe.length > 0) {
241 console.log(
242 `[DEBUG] Issue #${issue.number} - has activity after duplicate comment, skipping`
243 );
244 continue;
245 }
246
247 console.log(
248 `[DEBUG] Issue #${issue.number} - checking reactions on duplicate comment...`
249 );
250 const reactions: GitHubReaction[] = await githubRequest(
251 `/repos/${owner}/${repo}/issues/comments/${lastDupeComment.id}/reactions`,
252 token
253 );
254 console.log(
255 `[DEBUG] Issue #${issue.number} - duplicate comment has ${reactions.length} reactions`
256 );
257
258 const authorThumbsDown = reactions.some(
259 (reaction) =>
260 reaction.user.id === issue.user.id && reaction.content === "-1"
261 );
262 console.log(
263 `[DEBUG] Issue #${issue.number} - author thumbs down reaction: ${authorThumbsDown}`
264 );
265
266 if (authorThumbsDown) {
267 console.log(
268 `[DEBUG] Issue #${issue.number} - author disagreed with duplicate detection, skipping`
269 );
270 continue;
271 }
272
273 const duplicateIssueNumber = extractDuplicateIssueNumber(lastDupeComment.body);
274 if (!duplicateIssueNumber) {
275 console.log(
276 `[DEBUG] Issue #${issue.number} - could not extract duplicate issue number from comment, skipping`
277 );
278 continue;
279 }
280
281 candidateCount++;
282 const issueUrl = `https://github.com/${owner}/${repo}/issues/${issue.number}`;
283
284 try {
285 console.log(
286 `[INFO] Auto-closing issue #${issue.number} as duplicate of #${duplicateIssueNumber}: ${issueUrl}`
287 );
288 await closeIssueAsDuplicate(owner, repo, issue.number, duplicateIssueNumber, token);
289 console.log(
290 `[SUCCESS] Successfully closed issue #${issue.number} as duplicate of #${duplicateIssueNumber}`
291 );
292 } catch (error) {
293 console.error(
294 `[ERROR] Failed to close issue #${issue.number} as duplicate: ${error}`
295 );
296 }
297 }
298
299 console.log(
300 `[DEBUG] Script completed. Processed ${processedCount} issues, found ${candidateCount} candidates for auto-close`
301 );
302}
303
304autoCloseDuplicates().catch(console.error);
305
306// Make it a module
307export {};