Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-27T23:30:54.286Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

github-check-new-issue-for-duplicates.py

966 lines · 40.6 KB · python
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
Served at tenant.openagents/omega Member data and write actions are omitted.