Jeremiah Lowin commited on
Commit
4b0e2e7
·
unverified ·
1 Parent(s): 34e663d

Add dedupe workflow (#1454)

Browse files
.claude/commands/dedupe.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ allowed-tools: Bash(gh issue view:*), Bash(gh search:*), Bash(gh issue list:*), Bash(gh api:*), Bash(gh issue comment:*), Task
3
+ description: Find duplicate GitHub issues
4
+ ---
5
+
6
+ Find up to 3 likely duplicate issues for a given GitHub issue.
7
+
8
+ To do this, follow these steps precisely:
9
+
10
+ 1. Use an agent to check if the Github issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
11
+
12
+ 2. Use an agent to view a Github issue, and ask the agent to return a summary of the issue
13
+
14
+ 3. Then, launch 5 parallel agents to search Github for duplicates of this issue, using diverse keywords and search approaches, using the summary from #1
15
+
16
+ 4. Next, feed the results from #1 and #2 into another agent, so that it can filter out false positives, that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
17
+
18
+ 5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates)
19
+
20
+ Notes (be sure to tell this to your agents, too):
21
+ - Use `gh` to interact with Github, rather than web fetch
22
+ - Do not use other tools, beyond `gh` (eg. don't use other MCP servers, file edit, etc.)
23
+ - Make a todo list first
24
+ - For your comment, follow the following format precisely (assuming for this example that you found 3 suspected duplicates):
25
+
26
+ ---
27
+
28
+ Found 3 possible duplicate issues:
29
+ 1. #123: Issue title here
30
+ 2. #456: Another issue title
31
+ 3. #789: Third issue title
32
+
33
+ This issue will be automatically closed as a duplicate in 3 days.
34
+ - If your issue is a duplicate, please close it and 👍 the existing issue instead
35
+ - To prevent auto-closure, add a comment or 👎 this comment
36
+
37
+ ---
.github/workflows/auto-close-duplicates.yml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Auto-close duplicate issues
2
+ description: Auto-closes issues that are duplicates of existing issues
3
+ on:
4
+ schedule:
5
+ - cron: "0 9 * * *" # Run daily at 9 AM UTC
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ auto-close-duplicates:
10
+ runs-on: ubuntu-latest
11
+ timeout-minutes: 10
12
+ permissions:
13
+ contents: read
14
+ issues: write
15
+
16
+ steps:
17
+ - name: Checkout repository
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Install uv
21
+ uses: astral-sh/setup-uv@v4
22
+
23
+ - name: Auto-close duplicate issues
24
+ run: uv run scripts/auto_close_duplicates.py
25
+ env:
26
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27
+ GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
28
+ GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }}
.github/workflows/claude-dedupe-issues.yml ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Marvin Issue Dedupe
2
+ description: Automatically dedupe GitHub issues using Marvin
3
+ on:
4
+ issues:
5
+ types: [opened]
6
+ workflow_dispatch:
7
+ inputs:
8
+ issue_number:
9
+ description: "Issue number to process for duplicate detection"
10
+ required: true
11
+ type: string
12
+
13
+ jobs:
14
+ claude-dedupe-issues:
15
+ runs-on: ubuntu-latest
16
+ timeout-minutes: 10
17
+ permissions:
18
+ contents: read
19
+ issues: write
20
+
21
+ steps:
22
+ - name: Checkout repository
23
+ uses: actions/checkout@v4
24
+
25
+ - name: Generate Marvin App token
26
+ id: marvin-token
27
+ uses: actions/create-github-app-token@v1
28
+ with:
29
+ app-id: ${{ secrets.MARVIN_APP_ID }}
30
+ private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
31
+
32
+ - name: Run Marvin slash command
33
+ uses: anthropics/claude-code-base-action@beta
34
+ with:
35
+ prompt: "/dedupe ${{ github.repository }}/issues/${{ github.event.issue.number || inputs.issue_number }}"
36
+ anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
37
+ github_token: ${{ steps.marvin-token.outputs.token }}
scripts/auto_close_duplicates.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = [
5
+ # "httpx",
6
+ # ]
7
+ # ///
8
+ """
9
+ Auto-close duplicate GitHub issues.
10
+
11
+ This script runs on a schedule to automatically close issues that have been
12
+ marked as duplicates and haven't received any preventing activity.
13
+ """
14
+
15
+ import os
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timedelta, timezone
18
+
19
+ import httpx
20
+
21
+
22
+ @dataclass
23
+ class Issue:
24
+ """Represents a GitHub issue."""
25
+
26
+ number: int
27
+ title: str
28
+ state: str
29
+ created_at: str
30
+ user_id: int
31
+ user_login: str
32
+
33
+
34
+ @dataclass
35
+ class Comment:
36
+ """Represents a GitHub comment."""
37
+
38
+ id: int
39
+ body: str
40
+ created_at: str
41
+ user_id: int
42
+ user_login: str
43
+ user_type: str
44
+
45
+
46
+ @dataclass
47
+ class Reaction:
48
+ """Represents a reaction on a comment."""
49
+
50
+ user_id: int
51
+ user_login: str
52
+ content: str
53
+
54
+
55
+ class GitHubClient:
56
+ """Client for interacting with GitHub API."""
57
+
58
+ def __init__(self, token: str, owner: str, repo: str):
59
+ self.token = token
60
+ self.owner = owner
61
+ self.repo = repo
62
+ self.headers = {
63
+ "Authorization": f"token {token}",
64
+ "Accept": "application/vnd.github.v3+json",
65
+ }
66
+ self.base_url = f"https://api.github.com/repos/{owner}/{repo}"
67
+
68
+ def get_open_issues(
69
+ self, created_before: datetime, page: int = 1, per_page: int = 100
70
+ ) -> list[Issue]:
71
+ """Fetch open issues created before a certain date."""
72
+ url = f"{self.base_url}/issues"
73
+ issues = []
74
+
75
+ with httpx.Client() as client:
76
+ response = client.get(
77
+ url,
78
+ headers=self.headers,
79
+ params={"state": "open", "per_page": per_page, "page": page},
80
+ )
81
+
82
+ if response.status_code != 200:
83
+ print(f"Error fetching issues: {response.status_code}")
84
+ return issues
85
+
86
+ data = response.json()
87
+ for item in data:
88
+ # Skip pull requests
89
+ if "pull_request" in item:
90
+ continue
91
+
92
+ created_at = datetime.fromisoformat(
93
+ item["created_at"].replace("Z", "+00:00")
94
+ )
95
+ if created_at <= created_before:
96
+ issues.append(
97
+ Issue(
98
+ number=item["number"],
99
+ title=item["title"],
100
+ state=item["state"],
101
+ created_at=item["created_at"],
102
+ user_id=item["user"]["id"],
103
+ user_login=item["user"]["login"],
104
+ )
105
+ )
106
+
107
+ return issues
108
+
109
+ def get_issue_comments(self, issue_number: int) -> list[Comment]:
110
+ """Fetch all comments for an issue."""
111
+ url = f"{self.base_url}/issues/{issue_number}/comments"
112
+ comments = []
113
+
114
+ with httpx.Client() as client:
115
+ page = 1
116
+ while True:
117
+ response = client.get(
118
+ url, headers=self.headers, params={"page": page, "per_page": 100}
119
+ )
120
+
121
+ if response.status_code != 200:
122
+ break
123
+
124
+ data = response.json()
125
+ if not data:
126
+ break
127
+
128
+ for comment_data in data:
129
+ comments.append(
130
+ Comment(
131
+ id=comment_data["id"],
132
+ body=comment_data["body"],
133
+ created_at=comment_data["created_at"],
134
+ user_id=comment_data["user"]["id"],
135
+ user_login=comment_data["user"]["login"],
136
+ user_type=comment_data["user"]["type"],
137
+ )
138
+ )
139
+
140
+ page += 1
141
+ if page > 10: # Safety limit
142
+ break
143
+
144
+ return comments
145
+
146
+ def get_comment_reactions(
147
+ self, issue_number: int, comment_id: int
148
+ ) -> list[Reaction]:
149
+ """Fetch reactions for a specific comment."""
150
+ url = f"{self.base_url}/issues/{issue_number}/comments/{comment_id}/reactions"
151
+ reactions = []
152
+
153
+ with httpx.Client() as client:
154
+ response = client.get(url, headers=self.headers)
155
+
156
+ if response.status_code != 200:
157
+ return reactions
158
+
159
+ data = response.json()
160
+ for reaction_data in data:
161
+ reactions.append(
162
+ Reaction(
163
+ user_id=reaction_data["user"]["id"],
164
+ user_login=reaction_data["user"]["login"],
165
+ content=reaction_data["content"],
166
+ )
167
+ )
168
+
169
+ return reactions
170
+
171
+ def close_issue(self, issue_number: int, comment: str) -> bool:
172
+ """Close an issue with a comment."""
173
+ # First add the comment
174
+ comment_url = f"{self.base_url}/issues/{issue_number}/comments"
175
+ with httpx.Client() as client:
176
+ response = client.post(
177
+ comment_url, headers=self.headers, json={"body": comment}
178
+ )
179
+
180
+ if response.status_code != 201:
181
+ print(f"Failed to add comment to issue #{issue_number}")
182
+ return False
183
+
184
+ # Then close the issue
185
+ issue_url = f"{self.base_url}/issues/{issue_number}"
186
+ with httpx.Client() as client:
187
+ response = client.patch(
188
+ issue_url, headers=self.headers, json={"state": "closed"}
189
+ )
190
+
191
+ return response.status_code == 200
192
+
193
+
194
+ def find_duplicate_comment(comments: list[Comment]) -> Comment | None:
195
+ """Find a bot comment marking the issue as duplicate."""
196
+ for comment in comments:
197
+ # Check for the specific duplicate message format
198
+ body_lower = comment.body.lower()
199
+ if (
200
+ "possible duplicate issue" in body_lower
201
+ and "this issue will be automatically closed as a duplicate" in body_lower
202
+ ):
203
+ return comment
204
+ return None
205
+
206
+
207
+ def should_close_as_duplicate(
208
+ issue: Issue,
209
+ duplicate_comment: Comment,
210
+ all_comments: list[Comment],
211
+ reactions: list[Reaction],
212
+ ) -> bool:
213
+ """Determine if an issue should be closed as duplicate."""
214
+
215
+ # Check if comment is old enough (3 days)
216
+ comment_date = datetime.fromisoformat(
217
+ duplicate_comment.created_at.replace("Z", "+00:00")
218
+ )
219
+ three_days_ago = datetime.now(timezone.utc) - timedelta(days=3)
220
+
221
+ if comment_date > three_days_ago:
222
+ return False
223
+
224
+ # Check for preventing reactions (thumbs down)
225
+ for reaction in reactions:
226
+ if reaction.content in ["-1", "confused"]:
227
+ print(
228
+ f"Issue #{issue.number}: Has preventing reaction from {reaction.user_login}"
229
+ )
230
+ return False
231
+
232
+ # Check for user activity after the duplicate comment
233
+ for comment in all_comments:
234
+ comment_date_check = datetime.fromisoformat(
235
+ comment.created_at.replace("Z", "+00:00")
236
+ )
237
+ if comment_date_check > comment_date:
238
+ # Issue author commented after duplicate marking
239
+ if comment.user_id == issue.user_id:
240
+ print(
241
+ f"Issue #{issue.number}: Author commented after duplicate marking"
242
+ )
243
+ return False
244
+
245
+ return True
246
+
247
+
248
+ def main():
249
+ """Main entry point for auto-closing duplicate issues."""
250
+ print("[DEBUG] Starting auto-close duplicates script")
251
+
252
+ # Get environment variables
253
+ token = os.environ.get("GITHUB_TOKEN")
254
+ if not token:
255
+ raise ValueError("GITHUB_TOKEN environment variable is required")
256
+
257
+ owner = os.environ.get("GITHUB_REPOSITORY_OWNER", "jlowin")
258
+ repo = os.environ.get("GITHUB_REPOSITORY_NAME", "fastmcp")
259
+
260
+ print(f"[DEBUG] Repository: {owner}/{repo}")
261
+
262
+ # Initialize client
263
+ client = GitHubClient(token, owner, repo)
264
+
265
+ # Get issues created more than 3 days ago
266
+ three_days_ago = datetime.now(timezone.utc) - timedelta(days=3)
267
+
268
+ all_issues = []
269
+ page = 1
270
+
271
+ while page <= 20: # Safety limit
272
+ issues = client.get_open_issues(three_days_ago, page=page)
273
+ if not issues:
274
+ break
275
+ all_issues.extend(issues)
276
+ page += 1
277
+
278
+ print(f"[DEBUG] Found {len(all_issues)} open issues created more than 3 days ago")
279
+
280
+ processed_count = 0
281
+ closed_count = 0
282
+
283
+ for issue in all_issues:
284
+ processed_count += 1
285
+
286
+ if processed_count % 10 == 0:
287
+ print(f"[DEBUG] Processed {processed_count}/{len(all_issues)} issues")
288
+
289
+ # Get comments for this issue
290
+ comments = client.get_issue_comments(issue.number)
291
+
292
+ # Look for duplicate marking comment
293
+ duplicate_comment = find_duplicate_comment(comments)
294
+ if not duplicate_comment:
295
+ continue
296
+
297
+ print(f"[DEBUG] Issue #{issue.number} has duplicate comment")
298
+
299
+ # Get reactions on the duplicate comment
300
+ reactions = client.get_comment_reactions(issue.number, duplicate_comment.id)
301
+
302
+ # Check if we should close
303
+ if should_close_as_duplicate(issue, duplicate_comment, comments, reactions):
304
+ close_message = (
305
+ "Closing this issue as a duplicate based on the automated analysis above.\n\n"
306
+ "The duplicate issues identified contain existing discussions and potential solutions. "
307
+ "Please add your 👍 to those issues if they match your use case.\n\n"
308
+ "If this was closed in error, please leave a comment explaining why this is not "
309
+ "a duplicate and we'll reopen it."
310
+ )
311
+
312
+ if client.close_issue(issue.number, close_message):
313
+ print(f"[SUCCESS] Closed issue #{issue.number} as duplicate")
314
+ closed_count += 1
315
+ else:
316
+ print(f"[ERROR] Failed to close issue #{issue.number}")
317
+
318
+ print(f"[DEBUG] Processing complete. Closed {closed_count} duplicate issues")
319
+
320
+
321
+ if __name__ == "__main__":
322
+ main()