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