identify_potential_duplicate_issues.yml

  1name: Identify potential duplicates among new bug/crash reports
  2
  3on:
  4  issues:
  5    types: [opened]
  6  workflow_dispatch:
  7    inputs:
  8      issue_number:
  9        description: "Issue number to analyze (for testing)"
 10        required: true
 11        type: number
 12
 13concurrency:
 14  group: potential-duplicate-check-${{ github.event.issue.number || inputs.issue_number }}
 15  cancel-in-progress: true
 16
 17jobs:
 18  identify-duplicates:
 19    # For manual testing, allow running on any branch; for automatic runs, only on main repo
 20    if: github.event_name == 'workflow_dispatch' || github.repository == 'zed-industries/zed'
 21    runs-on: ubuntu-latest
 22    timeout-minutes: 5
 23
 24    permissions:
 25      contents: read
 26      issues: read
 27
 28    steps:
 29      - name: Get github app token
 30        id: get-app-token
 31        uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v2.1.4
 32        with:
 33          app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }}
 34          private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }}
 35          owner: zed-industries
 36
 37      - name: Fetch issue and check eligibility
 38        id: fetch-issue
 39        uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
 40        with:
 41          github-token: ${{ steps.get-app-token.outputs.token }}
 42          script: |
 43            const issueNumber = context.payload.issue?.number || ${{ inputs.issue_number || 0 }};
 44            if (!issueNumber) {
 45              core.setFailed('No issue number provided');
 46              return;
 47            }
 48
 49            const { data: issue } = await github.rest.issues.get({
 50              owner: context.repo.owner,
 51              repo: context.repo.repo,
 52              issue_number: issueNumber
 53            });
 54
 55            const typeName = issue.type?.name;
 56            const isTargetType = typeName === 'Bug' || typeName === 'Crash';
 57
 58            console.log(`Issue #${issueNumber}: "${issue.title}"`);
 59            console.log(`Issue type: ${typeName || '(none)'}`);
 60            console.log(`Is target type (Bug/Crash): ${isTargetType}`);
 61
 62            // Set default outputs for all paths
 63            core.setOutput('issue_number', issueNumber);
 64            core.setOutput('issue_title', issue.title);
 65            core.setOutput('issue_body', (issue.body || '').slice(0, 6000));
 66            core.setOutput('is_target_type', String(isTargetType));
 67            core.setOutput('is_staff', 'false');
 68            core.setOutput('should_continue', 'false');
 69
 70            if (!isTargetType) {
 71              console.log('::notice::Skipping - issue type is not Bug or Crash');
 72              return;
 73            }
 74
 75            // Check if author is staff (skip if so - they know what they're doing)
 76            const author = issue.user?.login || '';
 77            let isStaff = false;
 78            if (author) {
 79              try {
 80                const response = await github.rest.teams.getMembershipForUserInOrg({
 81                  org: 'zed-industries',
 82                  team_slug: 'staff',
 83                  username: author
 84                });
 85                isStaff = response.data.state === 'active';
 86              } catch (error) {
 87                if (error.status !== 404) throw error;
 88              }
 89            }
 90
 91            core.setOutput('is_staff', String(isStaff));
 92            if (isStaff) {
 93              console.log(`::notice::Skipping - author @${author} is a staff member`);
 94              return;
 95            }
 96
 97            core.setOutput('should_continue', 'true');
 98
 99      # ========================================================================
100      # PASS 1: Detect areas using Claude with the full area taxonomy
101      # ========================================================================
102      - name: "Pass 1: Detect areas with Claude"
103        if: steps.fetch-issue.outputs.should_continue == 'true'
104        id: detect-areas
105        env:
106          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY_ISSUE_DEDUP }}
107          ISSUE_TITLE: ${{ steps.fetch-issue.outputs.issue_title }}
108          ISSUE_BODY: ${{ steps.fetch-issue.outputs.issue_body }}
109        run: |
110          # shellcheck disable=SC2016
111          cat > /tmp/area_prompt.txt << 'PROMPT_EOF'
112          You are classifying a GitHub issue for the Zed code editor into area categories.
113
114          ## Issue Title
115          ISSUE_TITLE_PLACEHOLDER
116
117          ## Issue Body
118          ISSUE_BODY_PLACEHOLDER
119
120          ## Available Area Labels
121          (descriptions provided only where the label name isn't self-explanatory)
122
123          accessibility
124          ai, ai/acp (Agent Communication Protocol), ai/agent thread, ai/anthropic, ai/assistant, ai/bedrock, ai/codex, ai/copilot, ai/deepseek, ai/edit prediction, ai/gemini, ai/inline assistant, ai/lmstudio, ai/mcp (Model Context Protocol), ai/mistral, ai/ollama, ai/openai, ai/openai compatible, ai/openrouter, ai/qwen, ai/supermaven, ai/text thread, ai/zeta
125          auth
126          autocompletions
127          billing
128          cli
129          code actions
130          code folding
131          collab - real-time collaboration with other Zed users (screen sharing, shared editing). NOT for remote development over SSH.
132          collab/audio, collab/chat
133          command palette
134          controls/ime, controls/keybinds, controls/mouse
135          debugger, debugger/dap/CodeLLDB, debugger/dap/debugpy, debugger/dap/gdb, debugger/dap/javascript
136          design papercut - small UI/UX polish issues
137          dev containers - Docker-based development environments
138          diagnostics - LSP errors/warnings display
139          discoverability
140          editor, editor/brackets, editor/linked edits
141          extensions/infrastructure
142          file finder - fuzzy file search (Cmd/Ctrl+P)
143          gpui - Zed's internal UI rendering framework
144          inlay hints - inline hints from LSP (type annotations, parameter names)
145          installer-updater
146          integrations/environment - shell environment, PATH, env vars
147          integrations/git, integrations/git/blame, integrations/terminal
148          internationalization, internationalization/rtl support
149          keymap editor
150          language server, language server/server failure
151          languages/* - language-specific syntax, grammar, or LSP issues (e.g., languages/python, languages/rust, languages/typescript)
152          legal
153          logging
154          multi-buffer - viewing multiple files or search results in a single editor pane
155          multi-cursor
156          navigation - go to definition, find references, symbol search
157          network - proxy settings, connectivity, SSL certificates. NOT for collab.
158          onboarding
159          outline - document symbols/structure sidebar
160          parity/* - feature parity requests comparing to other editors (parity/vscode, parity/vim, parity/emacs, parity/jetbrains, parity/helix)
161          performance, performance/memory leak
162          permissions
163          popovers - hover cards, tooltips, autocomplete dropdowns
164          preview/images, preview/markdown
165          project panel - file tree sidebar
166          release notes
167          repl
168          search - project-wide search, find/replace
169          security & privacy, security & privacy/workspace trust
170          serialization - saving/restoring workspace state, undo history, folding state across restarts
171          settings, settings/ui
172          snippets
173          status bar
174          tasks - task runner integration
175          telemetry
176          tooling/* - external tool integrations (tooling/emmet, tooling/eslint, tooling/prettier, tooling/flatpak, tooling/nix)
177          tree-sitter - syntax parsing and highlighting engine
178          ui/animations, ui/dock, ui/file icons, ui/font, ui/menus, ui/minimap, ui/panel, ui/scaling, ui/scrolling, ui/tabs, ui/themes
179          workspace - window management, pane layout, project handling
180          zed account
181          zed.dev
182
183          ## Your Task
184
185          Based on the issue title and body, identify which areas this issue relates to.
186          - Select 1-5 areas that best match the issue
187          - Prefer more specific sub-areas when applicable (e.g., "ai/gemini" over just "ai")
188          - Only select areas that are clearly relevant
189
190          ## Response Format
191
192          Return ONLY a JSON object (no markdown fences, no explanation):
193          {
194            "areas": ["area1", "area2"],
195            "reasoning": "Brief explanation of why these areas were selected"
196          }
197          PROMPT_EOF
198
199          # Single quotes are intentional to prevent bash expansion; node reads env vars via process.env
200          # shellcheck disable=SC2016
201          node << 'SCRIPT_EOF'
202          const fs = require('fs');
203          let prompt = fs.readFileSync('/tmp/area_prompt.txt', 'utf8');
204          prompt = prompt.replace('ISSUE_TITLE_PLACEHOLDER', process.env.ISSUE_TITLE || '');
205          prompt = prompt.replace('ISSUE_BODY_PLACEHOLDER', process.env.ISSUE_BODY || '');
206          fs.writeFileSync('/tmp/area_prompt_final.txt', prompt);
207          SCRIPT_EOF
208
209          HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/area_response.json -X POST "https://api.anthropic.com/v1/messages" \
210            -H "Content-Type: application/json" \
211            -H "x-api-key: $ANTHROPIC_API_KEY" \
212            -H "anthropic-version: 2023-06-01" \
213            --data-binary @- << EOF
214          {
215            "model": "claude-sonnet-4-5-20250929",
216            "max_tokens": 256,
217            "messages": [{"role": "user", "content": $(jq -Rs . < /tmp/area_prompt_final.txt)}]
218          }
219          EOF
220          )
221
222          RESPONSE=$(< /tmp/area_response.json)
223
224          if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
225            echo "HTTP Error: $HTTP_CODE"
226            echo "$RESPONSE" | jq . 2>/dev/null || echo "$RESPONSE"
227            exit 1
228          fi
229
230          if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
231            echo "API Error:"
232            echo "$RESPONSE" | jq .
233            exit 1
234          fi
235
236          AREA_RESULT=$(echo "$RESPONSE" | jq -r '.content[0].text // empty')
237
238          if [ -z "$AREA_RESULT" ]; then
239            echo "Error: No response from Claude for area detection"
240            echo "$RESPONSE" | jq .
241            exit 1
242          fi
243
244          echo "Area detection result: $AREA_RESULT"
245
246          # Extract just the areas array, handling potential markdown fences
247          # shellcheck disable=SC2016
248          CLEAN_JSON=$(echo "$AREA_RESULT" | sed 's/^```json//; s/^```//; s/```$//' | tr -d '\n')
249          AREAS=$(echo "$CLEAN_JSON" | jq -r '.areas // [] | join(",")')
250          echo "Detected areas: $AREAS"
251
252          echo "detected_areas=$AREAS" >> "$GITHUB_OUTPUT"
253
254          INPUT_TOKENS=$(echo "$RESPONSE" | jq -r '.usage.input_tokens')
255          OUTPUT_TOKENS=$(echo "$RESPONSE" | jq -r '.usage.output_tokens')
256          echo "Pass 1 token usage - Input: $INPUT_TOKENS, Output: $OUTPUT_TOKENS"
257
258      # ========================================================================
259      # Use detected areas to filter magnets and search for candidates
260      # ========================================================================
261      - name: Filter magnets and search for candidates
262        if: steps.fetch-issue.outputs.should_continue == 'true'
263        id: gather-candidates
264        uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
265        with:
266          github-token: ${{ steps.get-app-token.outputs.token }}
267          script: |
268            // ============================================================
269            // KNOWN DUPLICATE MAGNETS (from #46355)
270            // ============================================================
271            const DUPLICATE_MAGNETS = [
272              { number: 37074, title: "Support history with external ACP agents", areas: ["ai", "ai/gemini", "ai/acp"] },
273              { number: 35780, title: "Zed consumes a lot of memory and CPU when opening ~/ or other large file trees", areas: ["workspace", "performance", "performance/memory leak", "integrations/git"] },
274              { number: 16965, title: "Support for non UTF-8 text encodings", areas: ["editor", "internationalization"] },
275              { number: 38109, title: "Zed out of sync with changes made outside of editor", areas: ["workspace"] },
276              { number: 16727, title: "Select text in markdown preview", areas: ["preview/markdown", "languages/markdown"] },
277              { number: 31102, title: "RTL Right-to-Left Text Input/Rendering Support", areas: ["internationalization"] },
278              { number: 7371, title: "Restarts should be non-destructive on workspace restore/reload", areas: ["workspace", "serialization"] },
279              { number: 7992, title: "Font rendering on LoDPI displays", areas: ["ui/font"] },
280              { number: 40018, title: "Windows Beta: Terminal overwrites text when resized and window overflow", areas: ["integrations/terminal"] },
281              { number: 29962, title: "Agent Panel: Cannot access zed hosted models (via Cloudflare HKG)", areas: ["ai", "network"] },
282              { number: 15097, title: "Serialize undo history (local and remote projects)", areas: ["workspace", "serialization"] },
283              { number: 29846, title: "Collapsed code blocks are not restored properly", areas: ["editor", "serialization", "code folding"] },
284              { number: 38799, title: "Poor search performance in large repositories", areas: ["performance", "search"] },
285              { number: 27283, title: "Inefficient memory use when opening large file in Zed", areas: ["performance"] },
286              { number: 39806, title: "Raspberry Pi OS (Trixie) Zed 0.207.3 Video Memory Corruption on Start", areas: ["gpui"] },
287              { number: 29970, title: "Unable to download any extensions (due to potential DigitalOcean IP block or ISP block)", areas: ["network"] },
288              { number: 29026, title: "Ability to copy/paste files from the system file manager", areas: ["workspace"] },
289              { number: 7940, title: "Zed is sometimes unresponsive when the OS awakes from sleep", areas: ["workspace"] },
290              { number: 37025, title: "Failed to generate thread summary", areas: ["ai"] },
291              { number: 16156, title: "Support for project settings to enable/disable/control AI features", areas: ["ai", "settings"] },
292              { number: 24752, title: "Extra horizontal scrolling when inline blame is enabled with soft wrapping", areas: ["editor"] },
293              { number: 20970, title: "Excessive memory consumption on project search with large files present", areas: ["performance/memory leak", "search", "multi-buffer"] },
294              { number: 12176, title: "Only some ligatures are being applied", areas: ["ui/font", "settings"] },
295              { number: 13564, title: "blade: Text is rendered either too thick or too thin", areas: ["ui/font"] },
296              { number: 38901, title: "Terminal freezes in Linux session when Ctrl+C is pressed before exit", areas: ["controls/keybinds", "integrations/terminal"] },
297              { number: 20167, title: "Support unsetting default keybindings", areas: ["controls/keybinds"] },
298              { number: 25469, title: "Tracking - Linux non-QWERTY keyboard support", areas: ["controls/keybinds"] },
299              { number: 29598, title: "Manual refresh on unsupported filesystems (nfs, fuse, exfat) without inotify/fsevents", areas: ["project panel"] },
300              { number: 14428, title: "Ordering of search tokens in file finder fuzzy match", areas: ["file finder"] },
301              { number: 20771, title: "Workspace: Reload to respect the desktop/workspace Zed windows were in after reload", areas: ["workspace", "serialization"] },
302              { number: 7465, title: "Lines with RTL text aren't rendered correctly", areas: ["editor", "internationalization/rtl support", "parity/vscode"] },
303              { number: 16120, title: "Large files without newlines (all on one line) cause Zed to hang/crash", areas: ["editor"] },
304              { number: 22703, title: "Syntax aware folding (folds.scm support)", areas: ["editor", "tree-sitter"] },
305              { number: 38927, title: "Find & Replace memory leak on large files", areas: ["performance", "performance/memory leak"] },
306              { number: 4560, title: "Improve streaming search speed", areas: ["performance", "search"] },
307              { number: 14053, title: "Linux Shortcuts don't work with non-latin / international keyboard layouts", areas: ["internationalization", "controls/keybinds"] },
308              { number: 31637, title: "High memory consumption in Project Search with large codebases", areas: ["performance/memory leak", "search"] },
309              { number: 11744, title: "Incorrect spacing of terminal font", areas: ["ui/font", "integrations/terminal"] },
310              { number: 4746, title: "Terminal Nerd Font rendering incorrect line height", areas: ["ui/font", "integrations/terminal"] },
311              { number: 10647, title: "User configurable mouse bindings (like keymap for key+mouse)", areas: ["controls/keybinds", "controls/mouse", "accessibility"] },
312              { number: 34865, title: "ctrl-w with pane::CloseActiveItem binding closes the project panel instead of the active pane", areas: ["controls/keybinds", "ui/panel"] },
313              { number: 12163, title: "Cannot see list of installed extensions when offline / disconnected", areas: ["network"] },
314              { number: 44630, title: "Tables do not render all columns in markdown preview", areas: ["preview/markdown"] },
315              { number: 39435, title: "Windows: Low fps in many cases", areas: ["gpui"] },
316              { number: 36227, title: "Zed becomes unresponsive when closing", areas: ["workspace"] },
317              { number: 44962, title: "Can not open file in zed if filename includes (1)", areas: ["workspace"] },
318              { number: 32318, title: "Zed hangs after exiting sleep mode in Linux", areas: ["workspace"] },
319              { number: 5120, title: "Add options to hide title and status bar", areas: ["settings", "status bar"] },
320              { number: 29323, title: "uv: Failed to detect Python venv correctly", areas: ["language server", "languages/python", "integrations/environment"] },
321              { number: 7450, title: "Support LSP Semantic Tokens", areas: ["language server", "languages", "ui/themes"] },
322              { number: 31846, title: "LSP: triggerCharacters for signature help declared by servers do not seem to be respected", areas: ["language server"] },
323              { number: 32792, title: "[SWAY] Zed window flashes rapidly on Sway/wlroots", areas: ["gpui"] },
324              { number: 28398, title: "Stale buffers should be removed from search multibuffer", areas: ["search", "multi-buffer"] },
325              { number: 35011, title: "Delete Key against remote Hosts Doesn't Delete Folders", areas: ["project panel"] },
326              { number: 8626, title: "Palette File Navigation - Preview File Content", areas: ["file finder"] },
327              { number: 31468, title: "Certain LSP features are not activated till you trigger them manually when working with a remote project", areas: ["language server/server failure", "autocompletions"] },
328              { number: 9789, title: "Zed checks for LSP updates when offline and disables LSPs irreversibly in the process", areas: ["language server/server failure"] },
329              { number: 21403, title: "Completions and code actions should not use uniform lists", areas: ["autocompletions", "popovers", "diagnostics"] },
330              { number: 15196, title: "Remote Project REPL support", areas: ["repl"] },
331            ];
332
333            const MAX_SEARCHES = 5;
334
335            const issueNumber = parseInt('${{ steps.fetch-issue.outputs.issue_number }}', 10);
336            const title = process.env.ISSUE_TITLE || '';
337            const body = process.env.ISSUE_BODY || '';
338            const detectedAreasStr = '${{ steps.detect-areas.outputs.detected_areas }}';
339            const detectedAreas = new Set(detectedAreasStr.split(',').filter(a => a.trim()));
340
341            console.log(`Detected areas from Claude: ${[...detectedAreas].join(', ') || '(none)'}`);
342
343            // Helper: check if two areas match (handles hierarchy like "ai" matching "ai/gemini")
344            function areasMatch(detected, magnetArea) {
345              if (detected === magnetArea) return true;
346              if (magnetArea.startsWith(detected + '/')) return true;
347              if (detected.startsWith(magnetArea + '/')) return true;
348              return false;
349            }
350
351            // Filter magnets based on detected areas
352            const relevantMagnets = DUPLICATE_MAGNETS.filter(magnet => {
353              if (detectedAreas.size === 0) return true;
354              return magnet.areas.some(magnetArea =>
355                [...detectedAreas].some(detected => areasMatch(detected, magnetArea))
356              );
357            }).slice(0, 20);
358
359            console.log(`Relevant duplicate magnets: ${relevantMagnets.length}`);
360
361            // Build search queries
362            const searchQueries = [];
363            const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
364
365            // 1. Keyword search from title
366            const stopwords = ['with', 'that', 'this', 'from', 'have', 'been', 'were', 'what', 'when',
367                               'where', 'which', 'while', 'does', 'doesn', 'should', 'would', 'could',
368                               'about', 'after', 'before', 'between', 'into', 'through', 'during',
369                               'above', 'below', 'under', 'again', 'further', 'then', 'once', 'here',
370                               'there', 'some', 'such', 'only', 'same', 'than', 'very', 'just', 'also',
371                               'work', 'working', 'works', 'issue', 'problem', 'error', 'bug', 'zed'];
372            const titleKeywords = title
373              .toLowerCase()
374              .replace(/[^\w\s]/g, ' ')
375              .split(/\s+/)
376              .filter(w => w.length >= 3 && !stopwords.includes(w))
377              .slice(0, 5);
378
379            if (titleKeywords.length >= 2) {
380              searchQueries.push({
381                type: 'keyword',
382                query: `repo:zed-industries/zed is:issue created:>${thirtyDaysAgo} ${titleKeywords.join(' ')}`
383              });
384            }
385
386            // 2. Area-based searches (using Claude-detected areas)
387            for (const area of [...detectedAreas].slice(0, 3)) {
388              searchQueries.push({
389                type: 'area',
390                query: `repo:zed-industries/zed is:issue is:open label:"area:${area}" created:>${thirtyDaysAgo}`
391              });
392            }
393
394            // 3. Look for error patterns in the body
395            const errorPatterns = body.match(/(?:error|panic|crash|failed|exception)[:\s]+[^\n]{10,100}/gi) || [];
396            if (errorPatterns.length > 0) {
397              const errorSnippet = errorPatterns[0]
398                .slice(0, 60)
399                .replace(/[^\w\s]/g, ' ')
400                .replace(/\s+/g, ' ')
401                .trim();
402              if (errorSnippet.length > 15) {
403                searchQueries.push({
404                  type: 'error',
405                  query: `repo:zed-industries/zed is:issue "${errorSnippet.slice(0, 40)}"`
406                });
407              }
408            }
409
410            // Execute searches and collect candidates
411            const candidates = [];
412            const seenIssues = new Set([issueNumber]);
413
414            for (const { type, query } of searchQueries.slice(0, MAX_SEARCHES)) {
415              try {
416                console.log(`Search (${type}): ${query}`);
417                const { data: results } = await github.rest.search.issuesAndPullRequests({
418                  q: query,
419                  sort: 'created',
420                  order: 'desc',
421                  per_page: 10
422                });
423
424                for (const item of results.items) {
425                  if (!seenIssues.has(item.number) && !item.pull_request) {
426                    seenIssues.add(item.number);
427                    candidates.push({
428                      number: item.number,
429                      title: item.title,
430                      state: item.state,
431                      created_at: item.created_at,
432                      body_preview: (item.body || '').slice(0, 800),
433                      source: type
434                    });
435                  }
436                }
437              } catch (error) {
438                console.log(`Search failed (${type}): ${error.message}`);
439              }
440            }
441
442            console.log(`Found ${candidates.length} candidates from searches`);
443
444            // Prepare issue data for Claude
445            const issueData = {
446              number: issueNumber,
447              title: title,
448              body: body.slice(0, 4000),
449            };
450
451            // Prepare output
452            core.setOutput('issue_data', JSON.stringify(issueData));
453            core.setOutput('duplicate_magnets', JSON.stringify(relevantMagnets));
454            core.setOutput('candidates', JSON.stringify(candidates.slice(0, 12)));
455            core.setOutput('detected_areas', [...detectedAreas].join(', '));
456            core.setOutput('should_analyze', (relevantMagnets.length > 0 || candidates.length > 0) ? 'true' : 'false');
457        env:
458          ISSUE_TITLE: ${{ steps.fetch-issue.outputs.issue_title }}
459          ISSUE_BODY: ${{ steps.fetch-issue.outputs.issue_body }}
460
461      # ========================================================================
462      # PASS 2: Analyze duplicates with Claude
463      # ========================================================================
464      - name: "Pass 2: Analyze duplicates with Claude"
465        if: |
466          steps.fetch-issue.outputs.should_continue == 'true' &&
467          steps.gather-candidates.outputs.should_analyze == 'true'
468        id: analyze
469        env:
470          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY_ISSUE_DEDUP }}
471          ISSUE_DATA: ${{ steps.gather-candidates.outputs.issue_data }}
472          DUPLICATE_MAGNETS: ${{ steps.gather-candidates.outputs.duplicate_magnets }}
473          CANDIDATES: ${{ steps.gather-candidates.outputs.candidates }}
474        run: |
475          # shellcheck disable=SC2016
476          cat > /tmp/prompt.txt << 'PROMPT_EOF'
477          You are analyzing a GitHub issue to determine if it might be a duplicate of an existing issue.
478
479          ## New Issue Being Analyzed
480          ISSUE_DATA_PLACEHOLDER
481
482          ## Known Frequently-Duplicated Issues (High Priority)
483          These issues have historically received many duplicate reports. Check these first.
484          DUPLICATE_MAGNETS_PLACEHOLDER
485
486          ## Recent Similar Issues Found by Search
487          CANDIDATES_PLACEHOLDER
488
489          ## Your Task
490
491          1. First, understand what the new issue is about:
492             - What specific bug or problem is being reported?
493             - What error messages, stack traces, or specific behaviors are mentioned?
494             - What component/feature is affected?
495
496          2. Check against the frequently-duplicated issues first (high priority):
497             - These are known "duplicate magnets" that often get re-reported
498             - If the new issue describes the same problem, it's likely a duplicate
499
500          3. Then check the recent similar issues:
501             - Look for issues describing the SAME bug, not just related topics
502
503          ## Duplicate Criteria (be strict!)
504
505          An issue IS a duplicate if:
506          - It describes the EXACT same bug with the same root cause
507          - It has the same error message or stack trace
508          - It has the same reproduction steps leading to the same outcome
509
510          An issue is NOT a duplicate if:
511          - It's merely related to the same feature/area
512          - It has similar symptoms but potentially different causes
513          - It mentions similar things but describes a different problem
514
515          Be VERY conservative. It's better to miss a duplicate than to incorrectly flag a unique issue.
516
517          ## Response Format
518
519          Return ONLY a JSON object (no markdown fences, no explanation before or after):
520          {
521            "is_potential_duplicate": boolean,
522            "confidence": "high" | "medium" | "low" | "none",
523            "potential_duplicates": [
524              {"number": integer, "title": "string", "similarity_reason": "string explaining why this might be the same bug"}
525            ],
526            "analysis_summary": "Brief explanation of what the new issue is about and your conclusion",
527            "recommendation": "flag_as_duplicate" | "needs_human_review" | "not_a_duplicate"
528          }
529          PROMPT_EOF
530
531          # Single quotes are intentional to prevent bash expansion; node reads env vars via process.env
532          # shellcheck disable=SC2016
533          node << 'SCRIPT_EOF'
534          const fs = require('fs');
535
536          let prompt = fs.readFileSync('/tmp/prompt.txt', 'utf8');
537          prompt = prompt.replace('ISSUE_DATA_PLACEHOLDER', process.env.ISSUE_DATA);
538          prompt = prompt.replace('DUPLICATE_MAGNETS_PLACEHOLDER', process.env.DUPLICATE_MAGNETS);
539          prompt = prompt.replace('CANDIDATES_PLACEHOLDER', process.env.CANDIDATES);
540
541          fs.writeFileSync('/tmp/prompt_final.txt', prompt);
542          SCRIPT_EOF
543
544          HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/response.json -X POST "https://api.anthropic.com/v1/messages" \
545            -H "Content-Type: application/json" \
546            -H "x-api-key: $ANTHROPIC_API_KEY" \
547            -H "anthropic-version: 2023-06-01" \
548            --data-binary @- << EOF
549          {
550            "model": "claude-sonnet-4-5-20250929",
551            "max_tokens": 1024,
552            "messages": [{"role": "user", "content": $(jq -Rs . < /tmp/prompt_final.txt)}]
553          }
554          EOF
555          )
556
557          RESPONSE=$(< /tmp/response.json)
558
559          if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
560            echo "HTTP Error: $HTTP_CODE"
561            echo "$RESPONSE" | jq . 2>/dev/null || echo "$RESPONSE"
562            exit 1
563          fi
564
565          if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
566            echo "API Error:"
567            echo "$RESPONSE" | jq .
568            exit 1
569          fi
570
571          ANALYSIS=$(echo "$RESPONSE" | jq -r '.content[0].text // empty')
572
573          if [ -z "$ANALYSIS" ]; then
574            echo "Error: No response from Claude"
575            echo "$RESPONSE" | jq .
576            exit 1
577          fi
578
579          {
580            echo "analysis<<ANALYSIS_EOF"
581            echo "$ANALYSIS"
582            echo "ANALYSIS_EOF"
583          } >> "$GITHUB_OUTPUT"
584
585          INPUT_TOKENS=$(echo "$RESPONSE" | jq -r '.usage.input_tokens')
586          OUTPUT_TOKENS=$(echo "$RESPONSE" | jq -r '.usage.output_tokens')
587          echo "Pass 2 token usage - Input: $INPUT_TOKENS, Output: $OUTPUT_TOKENS"
588
589      # ========================================================================
590      # Log results
591      # ========================================================================
592      - name: Log analysis results
593        if: |
594          steps.fetch-issue.outputs.should_continue == 'true' &&
595          !cancelled()
596        uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
597        with:
598          script: |
599            const issueNumber = parseInt('${{ steps.fetch-issue.outputs.issue_number }}', 10) || 0;
600            const issueTitle = process.env.ISSUE_TITLE || '';
601            const detectedAreas = '${{ steps.gather-candidates.outputs.detected_areas }}' || '(none)';
602            const shouldAnalyze = '${{ steps.gather-candidates.outputs.should_analyze }}' === 'true';
603            const analysisRaw = process.env.ANALYSIS_OUTPUT || '';
604
605            console.log('='.repeat(60));
606            console.log('DUPLICATE DETECTION RESULTS (TWO-PASS)');
607            console.log('='.repeat(60));
608            console.log(`Issue: #${issueNumber} - ${issueTitle}`);
609            console.log(`URL: https://github.com/zed-industries/zed/issues/${issueNumber}`);
610            console.log(`Detected Areas: ${detectedAreas}`);
611
612            if (!shouldAnalyze) {
613              console.log('\nNo duplicate magnets or candidates found - skipping analysis');
614              core.summary.addHeading(`✅ Issue #${issueNumber}: No similar issues found`, 2);
615              core.summary.addRaw(`\n**Title:** ${issueTitle}\n\n`);
616              core.summary.addRaw(`**Detected Areas:** ${detectedAreas}\n\n`);
617              core.summary.addRaw('No potential duplicates were found by search or in the known duplicate magnets list.\n');
618              await core.summary.write();
619              return;
620            }
621
622            if (!analysisRaw) {
623              console.log('\nNo analysis output received');
624              core.summary.addHeading(`⚠️ Issue #${issueNumber}: Analysis incomplete`, 2);
625              core.summary.addRaw(`**Detected Areas:** ${detectedAreas}\n\n`);
626              core.summary.addRaw('The Claude analysis step did not produce output. Check workflow logs.\n');
627              await core.summary.write();
628              return;
629            }
630
631            try {
632              let cleanJson = analysisRaw.trim();
633              if (cleanJson.startsWith('```')) {
634                cleanJson = cleanJson.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
635              }
636
637              const analysis = JSON.parse(cleanJson);
638
639              console.log(`\nIs Potential Duplicate: ${analysis.is_potential_duplicate}`);
640              console.log(`Confidence: ${analysis.confidence}`);
641              console.log(`Recommendation: ${analysis.recommendation}`);
642              console.log(`\nAnalysis Summary:\n${analysis.analysis_summary}`);
643
644              if (analysis.potential_duplicates && analysis.potential_duplicates.length > 0) {
645                console.log(`\nPotential Duplicates Found: ${analysis.potential_duplicates.length}`);
646                for (const dup of analysis.potential_duplicates) {
647                  console.log(`  - #${dup.number}: ${dup.title}`);
648                  console.log(`    Reason: ${dup.similarity_reason}`);
649                }
650              } else {
651                console.log('\nNo potential duplicates identified by analysis.');
652              }
653
654              console.log('\n' + '='.repeat(60));
655
656              const summaryIcon = analysis.is_potential_duplicate ? '⚠️' : '✅';
657              const summaryText = analysis.is_potential_duplicate
658                ? `Potential duplicate detected (${analysis.confidence} confidence)`
659                : 'No likely duplicates found';
660
661              core.summary.addHeading(`${summaryIcon} Issue #${issueNumber}: ${summaryText}`, 2);
662              core.summary.addRaw(`\n**Title:** ${issueTitle}\n\n`);
663              core.summary.addRaw(`**Detected Areas:** ${detectedAreas}\n\n`);
664              core.summary.addRaw(`**Recommendation:** \`${analysis.recommendation}\`\n\n`);
665              core.summary.addRaw(`**Summary:** ${analysis.analysis_summary}\n\n`);
666
667              if (analysis.potential_duplicates && analysis.potential_duplicates.length > 0) {
668                core.summary.addHeading('Potential Duplicates', 3);
669                const rows = analysis.potential_duplicates.map(d => [
670                  `[#${d.number}](https://github.com/zed-industries/zed/issues/${d.number})`,
671                  d.title.slice(0, 60) + (d.title.length > 60 ? '...' : ''),
672                  d.similarity_reason
673                ]);
674                core.summary.addTable([
675                  [{data: 'Issue', header: true}, {data: 'Title', header: true}, {data: 'Similarity Reason', header: true}],
676                  ...rows
677                ]);
678              }
679
680              await core.summary.write();
681
682            } catch (e) {
683              console.log('Failed to parse analysis output:', e.message);
684              console.log('Raw output:', analysisRaw);
685              core.summary.addHeading(`⚠️ Issue #${issueNumber}: Failed to parse analysis`, 2);
686              core.summary.addRaw(`**Detected Areas:** ${detectedAreas}\n\n`);
687              core.summary.addRaw(`Error: ${e.message}\n\nRaw output:\n\`\`\`\n${analysisRaw.slice(0, 1000)}\n\`\`\``);
688              await core.summary.write();
689            }
690        env:
691          ISSUE_TITLE: ${{ steps.fetch-issue.outputs.issue_title }}
692          ANALYSIS_OUTPUT: ${{ steps.analyze.outputs.analysis }}