Skip to repository content966 lines · 40.6 KB · python
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:27:34.242Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
github-check-new-issue-for-duplicates.py
1#!/usr/bin/env python3
2"""
3Comment on newly opened issues with possible duplicates and triage hints.
4
5This script is run by a GitHub Actions workflow when a new issue is opened. It:
61. Checks eligibility (bug/crash type or untyped, non-staff author)
72. Detects relevant areas using Claude + the area label taxonomy
83. Parses known "duplicate magnets" from tracking issue #46355
94. Searches for similar issues — open (last 60 days) and recently closed (last 30 days)
105. Asks Claude to sort open candidates into likely and possible duplicates, and
11 surface recently closed issues that may be useful triage context
126. Posts a comment if anything is found: a user-facing duplicate alert for likely
13 duplicates, and/or a collapsed triager-facing section for possible duplicates
14 and recently closed related issues
15
16Requires:
17 requests (pip install requests)
18
19Usage:
20 python github-check-new-issue-for-duplicates.py <issue_number>
21
22Environment variables:
23 GITHUB_TOKEN - GitHub token (org members: read, issues: read & write)
24 ANTHROPIC_API_KEY - Anthropic API key for Claude
25
26"""
27
28import argparse
29import json
30import os
31import re
32import sys
33import time
34from datetime import datetime, timedelta
35
36import requests
37
38GITHUB_API = "https://api.github.com"
39REPO_OWNER = "zed-industries"
40REPO_NAME = "zed"
41TRACKING_ISSUE_NUMBER = 46355
42STAFF_TEAM_SLUG = "staff"
43CLAUDE_MODEL = "claude-sonnet-4-6"
44
45# area prefixes to collapse in taxonomy (show summary instead of all sub-labels)
46PREFIXES_TO_COLLAPSE = ["languages", "parity", "tooling"]
47
48# stopwords to filter from title keyword searches (short words handled by len > 2 filter)
49STOPWORDS = {
50 "after", "all", "also", "and", "any", "but", "can't", "does", "doesn't",
51 "don't", "for", "from", "have", "just", "not", "only", "some", "that",
52 "the", "this", "when", "while", "with", "won't", "work", "working", "zed",
53}
54
55# HTTP statuses we'll retry on for GET requests
56TRANSIENT_HTTP_STATUSES = {429, 500, 502, 503, 504}
57
58
59def log(message):
60 """Print to stderr so it doesn't interfere with JSON output on stdout."""
61 print(message, file=sys.stderr)
62
63
64def github_api_get(path, params=None):
65 """Fetch JSON from the GitHub API, retrying transient failures. Raises on non-2xx status."""
66 url = f"{GITHUB_API}/{path.lstrip('/')}"
67 for attempt in range(3):
68 try:
69 response = requests.get(url, headers=GITHUB_HEADERS, params=params)
70 response.raise_for_status()
71 return response.json()
72 except requests.RequestException as e:
73 transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or (
74 isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES
75 )
76 if not transient or attempt == 2:
77 raise
78 wait = 2 ** attempt
79 log(f" Transient GitHub API error ({e}); retrying in {wait}s")
80 time.sleep(wait)
81
82
83def github_search_issues(query, per_page=15):
84 """Search issues, returning most recently created first."""
85 params = {"q": query, "sort": "created", "order": "desc", "per_page": per_page}
86 return github_api_get("/search/issues", params).get("items", [])
87
88
89def check_team_membership(org, team_slug, username):
90 """Check if user is an active member of a team."""
91 try:
92 data = github_api_get(f"/orgs/{org}/teams/{team_slug}/memberships/{username}")
93 return data.get("state") == "active"
94 except requests.HTTPError as e:
95 if e.response.status_code == 404:
96 return False
97 raise
98
99
100def post_comment(issue_number: int, body):
101 url = f"{GITHUB_API.rstrip('/')}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments"
102 response = requests.post(url, headers=GITHUB_HEADERS, json={"body": body})
103 response.raise_for_status()
104 log(f" Posted comment on #{issue_number}")
105
106
107def build_comment(likely_duplicates, possible_duplicates, related_closed_issues):
108 """Compose the full comment body. Returns empty string if there's nothing to post.
109
110 The comment has two sections, each optional:
111 - User-facing duplicate alert, rendered when likely_duplicates is non-empty.
112 - Collapsed triage context, rendered when there are possible duplicates or
113 related closed issues to surface for triagers.
114 """
115 sections = []
116
117 if likely_duplicates:
118 match_list = "\n".join(f"- #{m['number']}" for m in likely_duplicates)
119 explanations = "\n\n".join(
120 f"**#{m['number']}:** {m['explanation']}\n\n**Shared root cause:** {m['shared_root_cause']}"
121 for m in likely_duplicates
122 )
123 sections.append(f"""This issue appears to be a duplicate of:
124
125{match_list}
126
127**If this is indeed a duplicate:**
128Please close this issue and subscribe to the linked issue for updates (select "Close as not planned" → "Duplicate")
129
130**If this is a different issue:**
131No action needed. A maintainer will review this shortly.
132
133<details>
134<summary>Why were these issues selected?</summary>
135
136{explanations}
137
138</details>""")
139
140 if possible_duplicates or related_closed_issues:
141 parts = []
142 if possible_duplicates:
143 lines = [
144 f"- #{m['number']} — {m['explanation']}\n"
145 f" - Possible shared root cause: {m['shared_root_cause']}"
146 for m in possible_duplicates
147 ]
148 parts.append("**Possibly related open issues:**\n\n" + "\n".join(lines))
149 if related_closed_issues:
150 # state_reason is shown only for "duplicate" (the close type is otherwise
151 # already visible from GitHub's icon next to the issue number on render).
152 lines = [
153 f"- #{m['number']}"
154 f"{' (closed as duplicate)' if m['state_reason'] == 'duplicate' else ''}"
155 f" — {m['explanation']}"
156 for m in related_closed_issues
157 ]
158 parts.append("**Recently closed, possibly the same bug:**\n\n" + "\n".join(lines))
159 body = "\n\n".join(parts)
160 sections.append(f"""<details>
161<summary>Additional recent context for triagers</summary>
162
163{body}
164
165</details>""")
166
167 if not sections:
168 return ""
169
170 sections.append("---\n<sub>This is an automated analysis and might be incorrect.</sub>")
171 return "\n\n".join(sections)
172
173
174def _claude_request(api_key, payload):
175 """POST to the Claude Messages API, raise on non-2xx, log token usage, return parsed data."""
176 response = requests.post(
177 "https://api.anthropic.com/v1/messages",
178 headers={
179 "x-api-key": api_key,
180 "anthropic-version": "2023-06-01",
181 "content-type": "application/json",
182 },
183 json={"model": CLAUDE_MODEL, "temperature": 0.0, **payload},
184 )
185 response.raise_for_status()
186 data = response.json()
187
188 usage = data.get("usage", {})
189 log(f" Token usage - Input: {usage.get('input_tokens', 'N/A')}, Output: {usage.get('output_tokens', 'N/A')}")
190 return data
191
192
193def call_claude(api_key, system_prompt, user_content, max_tokens=1024):
194 """Send a message to Claude and return the text response. Raises on non-2xx status."""
195 data = _claude_request(api_key, {
196 "max_tokens": max_tokens,
197 "system": system_prompt,
198 "messages": [{"role": "user", "content": user_content}],
199 })
200
201 content = data.get("content", [])
202 if content and content[0].get("type") == "text":
203 return content[0].get("text") or ""
204 return ""
205
206
207def call_claude_tool(api_key, system_prompt, user_content, tool, max_tokens=1024):
208 """Call Claude, forcing it to invoke `tool`, and return the structured input dict.
209
210 Forcing a tool call makes the API emit schema-shaped JSON via its tool-use mechanism
211 instead of free-form text we'd have to parse out of prose or markdown fences. Raises on
212 non-2xx status, or if no tool_use block is returned.
213 """
214 data = _claude_request(api_key, {
215 "max_tokens": max_tokens,
216 "system": system_prompt,
217 "messages": [{"role": "user", "content": user_content}],
218 "tools": [tool],
219 "tool_choice": {"type": "tool", "name": tool["name"]},
220 })
221
222 if data.get("stop_reason") == "max_tokens":
223 log(" Warning: response hit max_tokens; structured output may be truncated")
224
225 for block in data.get("content", []):
226 if block.get("type") == "tool_use":
227 return block.get("input") or {}
228 raise ValueError(f"Claude returned no tool_use block for tool '{tool['name']}'")
229
230
231def fetch_issue(issue_number: int):
232 """Fetch issue from GitHub and return as a dict."""
233 log(f"Fetching issue #{issue_number}")
234
235 issue_data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}")
236 issue = {
237 "number": issue_number,
238 "title": issue_data["title"],
239 "body": issue_data.get("body") or "",
240 "author": (issue_data.get("user") or {}).get("login") or "",
241 "type": (issue_data.get("type") or {}).get("name"),
242 }
243
244 log(f" Title: {issue['title']}\n Type: {issue['type']}\n Author: {issue['author']}")
245 return issue
246
247
248def should_skip(issue):
249 """Check if issue should be skipped in duplicate detection process."""
250 if issue["type"] and issue["type"] not in ["Bug", "Crash"]:
251 log(f" Skipping: issue type '{issue['type']}' is not blank and not a bug/crash report")
252 return True
253
254 if issue["author"] and check_team_membership(REPO_OWNER, STAFF_TEAM_SLUG, issue["author"]):
255 log(f" Skipping: author '{issue['author']}' is a {STAFF_TEAM_SLUG} member")
256 return True
257
258 return False
259
260
261def fetch_area_labels():
262 """Fetch area:* labels from the repository. Returns list of {name, description} dicts."""
263 log("Fetching area labels")
264
265 labels = []
266 page = 1
267 while page_labels := github_api_get(
268 f"/repos/{REPO_OWNER}/{REPO_NAME}/labels",
269 params={"per_page": 100, "page": page},
270 ):
271 labels.extend(page_labels)
272 page += 1
273
274 # label["name"][5:] removes the "area:" prefix
275 area_labels = [
276 {"name": label["name"][5:], "description": label.get("description") or ""}
277 for label in labels
278 if label["name"].startswith("area:")
279 ]
280
281 log(f" Found {len(area_labels)} area labels")
282 return area_labels
283
284
285def format_taxonomy_for_claude(area_labels):
286 """Format area labels into a string for Claude, collapsing certain prefixes."""
287 lines = set()
288
289 for area in area_labels:
290 name = area["name"]
291 collapsible_prefix = next(
292 (p for p in PREFIXES_TO_COLLAPSE if name.startswith(f"{p}/")), None)
293
294 if collapsible_prefix:
295 lines.add(f"- {collapsible_prefix}/* (multiple specific sub-labels exist)")
296 else:
297 desc = area["description"]
298 lines.add(f"- {name}: {desc}" if desc else f"- {name}")
299
300 return "\n".join(sorted(lines))
301
302
303def detect_areas(anthropic_key, issue, area_labels):
304 """Use Claude to detect which area labels apply to the issue.
305
306 Claude may ignore the format instruction or hallucinate names, so the response
307 is validated against the canonical set of area labels.
308 """
309 log("Detecting areas with Claude")
310
311 taxonomy = format_taxonomy_for_claude(area_labels)
312 valid_areas = {label["name"] for label in area_labels}
313
314 system_prompt = """You analyze GitHub issues to identify which area labels apply.
315
316Decide the area from the user's stated symptom and reproduction steps. Issue bodies routinely
317contain pasted log output, crash dumps, stack traces, settings files, and template headers like
318"Attach Zed log file" or "Relevant Zed settings" — these are evidence about the symptom and
319should not push you toward labels like "logging" or "settings" unless the bug itself is about
320how that subsystem works.
321
322Respond with ONLY a comma-separated list of matching area names. No prose, no explanation,
323no markdown, no preamble — just the names.
324
325- Output at most 3 areas, ranked by relevance
326- Use exact area names from the taxonomy
327- If no areas clearly match, respond with: none
328- For languages/*, tooling/*, or parity/*, use the specific sub-label (e.g., "languages/rust",
329 tooling/eslint, parity/vscode)
330
331Examples of valid responses (each line is a complete response on its own):
332 editor, parity/vim
333 ai, ai/agent panel
334 none
335"""
336
337 user_content = f"""## Area Taxonomy
338{taxonomy}
339
340# Issue Title
341{issue['title']}
342
343# Issue Body
344{issue['body'][:4000]}"""
345
346 response = call_claude(anthropic_key, system_prompt, user_content, max_tokens=100).strip()
347 log(f" Detected areas: {response}")
348
349 if response.lower() == "none":
350 return []
351
352 valid, dropped = [], []
353 for area in response.split(","):
354 area = area.strip()
355 (valid if area in valid_areas else dropped).append(area)
356 if dropped:
357 log(f" Dropped {len(dropped)} unknown area(s) from Claude response: {dropped}")
358 return valid
359
360
361def parse_duplicate_magnets():
362 """Parse known duplicate magnets from tracking issue #46355.
363
364 Returns a list of magnets sorted by duplicate count (most duplicated first).
365 Magnets only have number, areas, and dupe_count — use enrich_magnets() to fetch
366 title and body_preview for the ones you need.
367 """
368 log(f"Parsing duplicate magnets from #{TRACKING_ISSUE_NUMBER}")
369
370 issue_data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{TRACKING_ISSUE_NUMBER}")
371 body = issue_data.get("body") or ""
372
373 # parse the issue body
374 # format: ## area_name
375 # - [N dupes] https://github.com/zed-industries/zed/issues/NUMBER
376 magnets = {} # number -> {number, areas, dupe_count}
377 current_area = None
378
379 for line in body.split("\n"):
380 # check for area header
381 if line.startswith("## "):
382 current_area = line[3:].strip()
383 continue
384
385 if not current_area or not line.startswith("-") or "/issues/" not in line:
386 continue
387
388 # parse: - [N dupes] https://github.com/.../issues/NUMBER
389 try:
390 dupe_count = int(line.split("[")[1].split()[0])
391 number = int(line.split("/issues/")[1].split()[0].rstrip(")"))
392 except (ValueError, IndexError):
393 continue
394
395 # skip "(unlabeled)": these magnets should match everything
396 is_unlabeled = current_area == "(unlabeled)"
397
398 if number in magnets:
399 if not is_unlabeled:
400 magnets[number]["areas"].append(current_area)
401 else:
402 magnets[number] = {
403 "number": number,
404 "areas": [] if is_unlabeled else [current_area],
405 "dupe_count": dupe_count,
406 }
407
408 magnet_list = sorted(magnets.values(), key=lambda m: m["dupe_count"], reverse=True)
409 log(f" Parsed {len(magnet_list)} duplicate magnets")
410 return magnet_list
411
412
413def enrich_magnets(magnets):
414 """Fetch title and body_preview for magnets from the API."""
415 log(f" Fetching details for {len(magnets)} magnets")
416 for magnet in magnets:
417 data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{magnet['number']}")
418 magnet["title"] = data["title"]
419 magnet["body_preview"] = (data.get("body") or "")[:1000]
420
421
422def areas_match(detected, magnet_area):
423 """Check if detected area matches magnet area. Matches broadly across hierarchy levels."""
424 return (
425 detected == magnet_area
426 or magnet_area.startswith(f"{detected}/")
427 or detected.startswith(f"{magnet_area}/")
428 )
429
430
431def filter_magnets_by_areas(magnets, detected_areas):
432 """Filter magnets based on detected areas."""
433 if not detected_areas:
434 return magnets
435
436 detected_set = set(detected_areas)
437
438 def matches(magnet):
439 # unlabeled magnets (empty areas) match everything
440 if not magnet["areas"]:
441 return True
442 return any(
443 areas_match(detected, magnet_area)
444 for detected in detected_set
445 for magnet_area in magnet["areas"]
446 )
447
448 return list(filter(matches, magnets))
449
450
451def search_for_similar_issues(issue, detected_areas, max_searches_per_state=6):
452 """Search for similar issues — both open and recently closed.
453
454 Runs two passes:
455 - Open issues: title keywords / error pattern unrestricted, area searches last 60 days.
456 - Closed issues: closed within the last 30 days (across all query types).
457
458 max_searches_per_state caps queries per state to keep token usage and context size bounded.
459 """
460 log("Searching for similar issues")
461
462 sixty_days_ago = (datetime.now() - timedelta(days=60)).strftime("%Y-%m-%d")
463 thirty_days_ago = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
464
465 title_keywords = [word for word in issue["title"].split() if word.lower() not in STOPWORDS and len(word) > 2]
466 keywords_query = " ".join(title_keywords) if title_keywords else None
467
468 # error pattern search: capture 5–90 chars after keyword, colon optional
469 error_pattern = r"(?i:\b(?:error|panicked|panic|failed)\b)\s*([^\n]{5,90})"
470 error_match = re.search(error_pattern, issue["body"])
471 error_snippet = error_match.group(1).strip() if error_match else None
472
473 def build_queries(base, area_window=None):
474 queries = []
475 if keywords_query:
476 queries.append(("title_keywords", f"{base} {keywords_query}"))
477 for area in detected_areas:
478 area_q = f'{base} label:"area:{area}"'
479 if area_window:
480 area_q += f" created:>{area_window}"
481 queries.append(("area_label", area_q))
482 if error_snippet:
483 queries.append(("error_pattern", f'{base} in:body "{error_snippet}"'))
484 return queries
485
486 open_queries = build_queries(
487 f"repo:{REPO_OWNER}/{REPO_NAME} is:issue is:open",
488 area_window=sixty_days_ago,
489 )
490 # closed pass: filter by close date so we catch issues closed recently regardless of
491 # when they were opened. closed:> already restricts the result set, so the per-query
492 # area window is unnecessary.
493 closed_queries = build_queries(
494 f"repo:{REPO_OWNER}/{REPO_NAME} is:issue is:closed closed:>{thirty_days_ago}",
495 )
496
497 seen_issues = {}
498 for state_label, queries in (
499 ("open", open_queries[:max_searches_per_state]),
500 ("closed", closed_queries[:max_searches_per_state]),
501 ):
502 for search_type, query in queries:
503 log(f" Search ({state_label} / {search_type}): {query}")
504 try:
505 results = github_search_issues(query, per_page=15)
506 for item in results:
507 number = item["number"]
508 if number != issue["number"] and number not in seen_issues:
509 body = item.get("body") or ""
510 seen_issues[number] = {
511 "number": number,
512 "title": item["title"],
513 "state": item.get("state", ""),
514 "state_reason": item.get("state_reason"),
515 "created_at": item.get("created_at", ""),
516 "body_preview": body[:1000],
517 "source": search_type,
518 }
519 except requests.RequestException as e:
520 log(f" Search failed: {e}")
521
522 similar_issues = list(seen_issues.values())
523 log(f" Found {len(similar_issues)} similar issues")
524 return similar_issues
525
526
527def analyze_duplicates(anthropic_key, issue, magnets, search_results):
528 """Use Claude to identify duplicates (open) and surface related closed issues.
529
530 Returns (likely_duplicates, possible_duplicates, related_closed_issues).
531 """
532 top_magnets = magnets[:10]
533 magnet_numbers = {m["number"] for m in top_magnets}
534
535 open_results = [r for r in search_results if r["state"] == "open" and r["number"] not in magnet_numbers]
536 closed_results = [r for r in search_results if r["state"] == "closed" and r["number"] not in magnet_numbers]
537
538 if not top_magnets and not open_results and not closed_results:
539 return [], [], []
540
541 log("Analyzing candidates with Claude")
542 log(f" Candidate pool: {len(top_magnets)} magnets, {len(open_results)} open search results, "
543 f"{len(closed_results)} closed search results (will pass {min(len(closed_results), 5)} closed)")
544 enrich_magnets(top_magnets)
545
546 closed_candidates_for_claude = closed_results[:5]
547 if closed_candidates_for_claude:
548 log(f" Closed candidates given to proposer: {[r['number'] for r in closed_candidates_for_claude]}")
549
550 candidates = [
551 {"number": m["number"], "title": m["title"], "body_preview": m["body_preview"],
552 "state": "open", "state_reason": None, "source": "known_duplicate_magnet"}
553 for m in top_magnets
554 ] + [
555 {"number": r["number"], "title": r["title"], "body_preview": r["body_preview"],
556 "state": r["state"], "state_reason": r["state_reason"], "source": "search_result"}
557 for r in open_results[:10] + closed_candidates_for_claude
558 ]
559
560 system_prompt = """You analyze GitHub issues to (a) identify duplicates among OPEN candidates
561and (b) surface recently CLOSED candidates that are useful triage context.
562
563Each candidate has a "state" field ("open" or "closed"), and closed candidates carry a
564"state_reason" ("completed", "not_planned", or "duplicate").
565
566# (a) Duplicates — OPEN candidates only
567
568A duplicate means: caused by the SAME BUG in the code, not just similar symptoms.
569
570CRITICAL DISTINCTION — shared symptoms vs shared root cause:
571- "models missing", "can't sign in", "editor hangs", "venv not detected" are SYMPTOMS that many
572 different bugs can produce. Two reports of the same symptom are NOT duplicates unless you can
573 identify a specific shared root cause.
574- A duplicate means: if a developer fixed the existing issue, the new issue would also be fixed.
575- If the issues just happen to be in the same feature area, or describe similar-sounding problems
576 with different specifics (different error messages, different triggers, different platforms,
577 different configurations), they are NOT duplicates.
578
579Sort duplicates into two buckets:
580- "likely_duplicates": Almost certainly the same bug. You can name a specific shared root cause, and
581 the reproduction steps / error messages / triggers are consistent.
582- "possible_duplicates": Likely the same bug based on specific technical details, but some
583 uncertainty remains.
584- Do NOT include issues that merely share symptoms, affect the same feature area, or sound similar
585 at a surface level.
586
587Examples of things that are NOT duplicates:
588- Two issues about "Copilot models not showing" — one caused by a Zed update breaking the model list,
589 the other caused by the user's plan not including those models.
590- Two issues about "Zed hangs" — one triggered by network drives, the other by large projects.
591- Two issues about "can't sign in" — one caused by a missing system package, the other by a server-side error.
592
593For OPEN duplicates (either bucket), false positives are MUCH worse than false negatives — they
594waste the time of both the issue author and the maintainers. When in doubt, omit.
595
596# (b) Closed candidates that may be the same bug — CLOSED candidates only
597
598The goal is NOT a "related reading" list. The goal is to surface closed issues where the
599new issue is plausibly the SAME bug — a duplicate that just happens to be filed against a
600closed predecessor instead of an open one. Empty is preferable to weak filler — triagers
601lose trust in this section quickly if it's stretched. The same false-positives-are-worse
602asymmetry as for duplicates applies here.
603
604The bar: a triager reading this should be able to act — ask the reporter to retest a fix,
605point at a known design decision that already declined this request, or point at the
606canonical bug this is a duplicate of. "Useful context" or "shared area" is NOT a reason
607to include.
608
609Omit a candidate if ANY of these apply (in observed practice, almost everything does):
610
6111. Self-contradiction. If you find yourself writing "while focused on X rather than Y",
612 "although this is about A, the new issue is about B", "this issue focuses on... rather
613 than...", or any acknowledgment that the candidate isn't on the same topic — STOP.
614 You've already made the case for omitting it.
615
6162. Fabricated specifics. Every concrete claim about the candidate (its trigger, its scope,
617 its conditions) must be visible in the candidate's title or body preview. Specifics
618 like "when X happens", "under Y conditions", "specifically affecting Z" that aren't
619 supported by the candidate's actual text mean you're inventing details to fit the new
620 issue. Omit.
621
6223. Weasel phrases. Paraphrases of these all indicate you don't have a real claim:
623 "may indicate similar...", "could provide context for...", "shows / demonstrates recent
624 attention to...", "indicates the team has considered...", "demonstrates a pattern
625 of...", "may provide useful context...". STOP and omit.
626
6274. Retest by default. The "reporter may need to retest on the latest build" framing only
628 applies when the candidate's symptom is literally the same as the new issue's. It is
629 NOT a default justification for "this was a recent fix in roughly the same area."
630
6315. Same area / feature, different mechanism. Examples to omit:
632 - "ARM compile failure" alongside "ARM runtime perf" — same area, different mechanism.
633 - "Worktree path bug" alongside "worktree display label confusion" — same feature,
634 unrelated.
635
6366. Vague catch-all candidate. A closed issue like "Zed is slow" / "performance" / "agent
637 panel UX" that could be cited next to almost any new bug is filler. If you'd reuse the
638 same closed issue across many unrelated new issues, omit.
639
6407. Label or single-keyword overlap. A closed issue whose only connection is a shared
641 area:* label or one shared keyword is not relevant.
642
643Worth surfacing — strict examples:
644- A recently fixed ("completed") issue with the SAME specific trigger as the new issue —
645 triager can ask the reporter to retest on the latest build.
646- A cluster of "not_planned" closures about the EXACT same request — known design choice
647 the triager can point to.
648- A previously triaged "duplicate" pointing at the same canonical issue, or sharing the
649 same specific mechanism.
650
651Count: typically 0 or 1. Never more than 2 unless there's an obvious cluster of identical
652"not_planned" reports. 0 is a normal outcome.
653
654# Output
655
656Report your verdict by calling the report_duplicate_analysis tool. Fill the "reasoning"
657field first with a brief scratchpad weighing the strongest candidates and whether they
658share a root cause, then fill each bucket. Use empty arrays where nothing relevant is
659found."""
660
661 user_content = f"""## New Issue #{issue['number']}
662**Title:** {issue['title']}
663
664**Body:**
665{issue['body'][:3000]}
666
667## Existing Issues to Compare
668{json.dumps(candidates, indent=2)}"""
669
670 duplicate_match_schema = {
671 "type": "object",
672 "properties": {
673 "number": {"type": "integer", "description": "The candidate issue number"},
674 "shared_root_cause": {"type": "string", "description": "The specific bug/root cause shared by both issues"},
675 "explanation": {"type": "string", "description": "Brief explanation with concrete evidence from both issues"},
676 },
677 "required": ["number", "shared_root_cause", "explanation"],
678 }
679 analysis_tool = {
680 "name": "report_duplicate_analysis",
681 "description": "Report the duplicate analysis for the new issue.",
682 "input_schema": {
683 "type": "object",
684 "properties": {
685 "reasoning": {
686 "type": "string",
687 "description": "A brief scratchpad (at most 2-3 sentences) weighing the strongest "
688 "candidates and whether they share a root cause. Be terse.",
689 "maxLength": 700,
690 },
691 "likely_duplicates": {"type": "array", "items": duplicate_match_schema},
692 "possible_duplicates": {"type": "array", "items": duplicate_match_schema},
693 "related_closed_issues": {
694 "type": "array",
695 "items": {
696 "type": "object",
697 "properties": {
698 "number": {"type": "integer", "description": "The candidate issue number"},
699 "explanation": {"type": "string", "description": "Brief explanation of why this is useful triage context"},
700 },
701 "required": ["number", "explanation"],
702 },
703 },
704 },
705 "required": ["reasoning", "likely_duplicates", "possible_duplicates", "related_closed_issues"],
706 },
707 }
708
709 data = call_claude_tool(anthropic_key, system_prompt, user_content, analysis_tool, max_tokens=3072)
710 if data.get("reasoning"):
711 log(f" Reasoning: {data['reasoning']}")
712
713 likely = data.get("likely_duplicates", [])
714 possible = data.get("possible_duplicates", [])
715 closed = data.get("related_closed_issues", [])
716
717 # Claude occasionally places a closed candidate in the duplicate buckets, or vice
718 # versa. Enforce that each match lives in the bucket consistent with the canonical
719 # state of the candidate we passed in.
720 candidate_states = {c["number"]: c["state"] for c in candidates}
721
722 def filter_by_state(items, expected_state, label):
723 kept, wrong = [], []
724 for m in items:
725 (kept if candidate_states.get(m["number"]) == expected_state else wrong).append(m)
726 if wrong:
727 log(f" Dropped {len(wrong)} from {label} with wrong/unknown state: {[m['number'] for m in wrong]}")
728 return kept
729
730 likely = filter_by_state(likely, "open", "likely_duplicates")
731 possible = filter_by_state(possible, "open", "possible_duplicates")
732 closed = filter_by_state(closed, "closed", "related_closed_issues")
733
734 # Avoid showing the same issue in both the user-facing alert and the triage section.
735 likely_numbers = {m["number"] for m in likely}
736 overlap = [m["number"] for m in possible if m["number"] in likely_numbers]
737 if overlap:
738 log(f" Dropped {len(overlap)} from possible_duplicates already in likely_duplicates: {overlap}")
739 possible = [m for m in possible if m["number"] not in likely_numbers]
740
741 log(f" Found {len(likely) + len(possible) + len(closed)} potential matches")
742 return likely, possible, closed
743
744
745CRITIQUE_SYSTEM_PROMPT = """You are evaluating ONE recently closed GitHub issue to decide whether a triager looking
746at a brand-new bug report would find it useful to be told about that closed issue.
747
748There is no slate to fill. There is no quota. You will be shown exactly one candidate.
749The default verdict is OMIT. Zero is the expected outcome for most candidates.
750
751A candidate is worth surfacing ONLY if the new issue is plausibly the SAME BUG as the
752closed one — a duplicate that happens to be filed against a closed predecessor. Concretely,
753the legitimate cases are exactly three:
754
755- The candidate was closed as "completed" (a fix shipped) AND the new issue has the same
756 specific trigger / symptom. The triager will ask the reporter to retest.
757- The candidate was closed as "not_planned" AND the new issue is the EXACT same request
758 (a feature decision the team already declined). The triager will point at it.
759- The candidate was closed as "duplicate" AND it pointed at the same canonical bug the new
760 issue describes, or it shares the same specific mechanism.
761
762"Same broad area", "similar-sounding symptom", or "recent attention to this subsystem" are
763NOT reasons to include. Omit them.
764
765Return "omit" if ANY of the following apply (in observed practice, almost everything does):
766
7671. Self-contradiction. If your reasoning includes "while focused on X rather than Y",
768 "although this is about A, the new issue is about B", "this issue focuses on... rather
769 than...", or any acknowledgment the candidate is on a different topic — you've already
770 decided to omit.
7712. Fabricated specifics. Every concrete claim about the candidate (its trigger, scope,
772 conditions) must be visible in the candidate's title or body preview. If you find
773 yourself describing the candidate using details that aren't in its text, you're
774 inventing details to fit the new issue. Omit.
7753. Weasel phrases. Paraphrases of "may indicate similar...", "could provide context
776 for...", "shows / demonstrates recent attention to...", "indicates the team has
777 considered...", "demonstrates a pattern of...", "may provide useful context..." —
778 these mean you don't have a real claim. Omit.
7794. Retest by default. The "reporter may need to retest on the latest build" framing only
780 applies when the closed issue's symptom is LITERALLY the same as the new issue's. "This
781 was a recent fix in roughly the same area" is not enough.
7825. Same area / feature, different mechanism. Same area label but different bug, different
783 code path, different trigger. Omit.
7846. Vague catch-all candidate. A closed issue like "Zed is slow" / "performance" / "agent
785 panel UX" that you could cite next to many unrelated new bugs. Omit.
7867. Label or single-keyword overlap. Only connection is a shared area:* label or one shared
787 keyword. Omit.
788
789Report your decision by calling the report_critique_verdict tool. Fill "rationale" first
790(one concise sentence), then "verdict". When "verdict" is "include", "rule_violated" must be
791null. When "verdict" is "omit", set "rule_violated" to the most relevant rule number, or
792null if the candidate is simply too unrelated for any rule to specifically apply."""
793
794
795CRITIQUE_VERDICT_TOOL = {
796 "name": "report_critique_verdict",
797 "description": "Report whether the closed candidate is worth surfacing to a triager.",
798 "input_schema": {
799 "type": "object",
800 "properties": {
801 "rationale": {
802 "type": "string",
803 "description": "One concise sentence justifying the verdict, grounded in the candidate's actual text.",
804 "maxLength": 400,
805 },
806 "verdict": {"type": "string", "enum": ["include", "omit"]},
807 "rule_violated": {
808 "type": ["integer", "null"],
809 "description": "The most relevant omit-rule number (1-7), or null when including.",
810 },
811 },
812 "required": ["rationale", "verdict"],
813 },
814}
815
816
817def critique_closed_candidates(anthropic_key, issue, proposed, search_results):
818 """Run a strict per-candidate critique pass over the proposer's closed candidates.
819
820 For each proposed match, call Claude with only the new issue and that single candidate
821 (blind to the proposer's rationale) and ask for a yes/no verdict. Default is omit.
822 Returns the subset of `proposed` that passes critique.
823 """
824 if not proposed:
825 log(" Critique: proposer surfaced 0 closed candidates; skipping")
826 return []
827
828 log(f" Critique: proposer surfaced {len(proposed)} closed candidate(s): "
829 f"{[m['number'] for m in proposed]}")
830
831 results_by_number = {r["number"]: r for r in search_results}
832 kept = []
833 for match in proposed:
834 number = match["number"]
835 candidate = results_by_number.get(number)
836 if candidate is None:
837 # Should not happen — analyze_duplicates only emits numbers from candidates it
838 # was given — but be defensive rather than crash the bot.
839 log(f" Critique: dropping #{number} — candidate context not found")
840 continue
841
842 state_reason = candidate.get("state_reason") or "unknown"
843 user_content = f"""## New Issue #{issue['number']}
844**Title:** {issue['title']}
845
846**Body:**
847{issue['body'][:3000]}
848
849## Closed Candidate #{number}
850**Title:** {candidate.get('title', '')}
851**State reason:** {state_reason}
852
853**Body preview:**
854{candidate.get('body_preview', '')}"""
855
856 log(f" Critique: evaluating #{number}")
857 try:
858 verdict_data = call_claude_tool(
859 anthropic_key, CRITIQUE_SYSTEM_PROMPT, user_content, CRITIQUE_VERDICT_TOOL, max_tokens=600
860 )
861 except (requests.RequestException, ValueError) as e:
862 # If the critique call fails, prefer omitting the candidate over posting noise.
863 log(f" Critique: verdict call failed for #{number} ({e}); omitting candidate")
864 continue
865
866 verdict = verdict_data.get("verdict")
867 rule = verdict_data.get("rule_violated")
868 rationale = verdict_data.get("rationale", "")
869
870 if verdict == "include":
871 log(f" Critique: keeping #{number} — {rationale}")
872 kept.append(match)
873 else:
874 rule_str = f"rule {rule}" if rule else "no specific rule"
875 log(f" Critique: omitting #{number} ({rule_str}) — {rationale}")
876
877 log(f" Critique: kept {len(kept)} of {len(proposed)} closed candidates")
878 return kept
879
880
881if __name__ == "__main__":
882 parser = argparse.ArgumentParser(description="Identify potential duplicate issues")
883 parser.add_argument("issue_number", type=int, help="Issue number to analyze")
884 parser.add_argument("--dry-run", action="store_true", help="Skip posting comment, just log what would be posted")
885 args = parser.parse_args()
886
887 github_token = os.environ.get("GITHUB_TOKEN")
888 anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
889
890 if not github_token:
891 log("Error: GITHUB_TOKEN not set")
892 sys.exit(1)
893 if not anthropic_key:
894 log("Error: ANTHROPIC_API_KEY not set")
895 sys.exit(1)
896
897 GITHUB_HEADERS = {
898 "Authorization": f"Bearer {github_token}",
899 "Accept": "application/vnd.github+json",
900 "X-GitHub-Api-Version": "2022-11-28",
901 }
902
903 issue = fetch_issue(args.issue_number)
904 if should_skip(issue):
905 print(json.dumps({"skipped": True}))
906 sys.exit(0)
907
908 # detect areas
909 detected_areas = detect_areas(anthropic_key, issue, fetch_area_labels())
910
911 # search for potential duplicates and related closed issues
912 all_magnets = parse_duplicate_magnets()
913 relevant_magnets = filter_magnets_by_areas(all_magnets, detected_areas)
914 search_results = search_for_similar_issues(issue, detected_areas)
915
916 # analyze candidates
917 likely_duplicates, possible_duplicates, related_closed_issues = analyze_duplicates(
918 anthropic_key, issue, relevant_magnets, search_results
919 )
920
921 # second-pass critique: prompt iteration on the proposer hit a ceiling around 30% noise.
922 # Re-evaluate each proposed closed candidate in isolation with a stricter prompt that
923 # has no slate to fill and is blind to the proposer's rationale.
924 related_closed_issues = critique_closed_candidates(
925 anthropic_key, issue, related_closed_issues, search_results
926 )
927
928 # resolve close reason from our search results (the source of truth) so we don't depend
929 # on Claude to faithfully echo it back
930 results_by_number = {r["number"]: r for r in search_results}
931 for m in related_closed_issues:
932 m["state_reason"] = results_by_number[m["number"]]["state_reason"]
933
934 comment_body = build_comment(likely_duplicates, possible_duplicates, related_closed_issues)
935 commented = False
936
937 if comment_body:
938 if args.dry_run:
939 log("Dry run - would post comment:\n" + "-" * 40 + "\n" + comment_body + "\n" + "-" * 40)
940 else:
941 log("Posting comment")
942 try:
943 post_comment(issue["number"], comment_body)
944 commented = True
945 except requests.RequestException as e:
946 log(f" Failed to post comment: {e}")
947 log(f" Comment we were trying to post:\n{comment_body}")
948 sys.exit(1)
949
950 print(json.dumps({
951 "skipped": False,
952 "issue": {
953 "number": issue["number"],
954 "title": issue["title"],
955 "author": issue["author"],
956 "type": issue["type"],
957 },
958 "detected_areas": detected_areas,
959 "magnets_count": len(relevant_magnets),
960 "search_results_count": len(search_results),
961 "likely_duplicates": likely_duplicates,
962 "possible_duplicates": possible_duplicates,
963 "related_closed_issues": related_closed_issues,
964 "commented": commented,
965 }))
966