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