surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
- Move GitHub workflow script logic to scripts/auto-close-duplicates.ts - Convert from inline JavaScript to standalone TypeScript module - Update workflow to use Bun instead of Node.js for better performance - Add proper TypeScript types and ES module syntax 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | description: Dry run - logs issues that would be auto-closed as duplicates after 3 days if no response |
| 3 | 3 | on: |
| 4 | 4 | schedule: |
| 5 | - - cron: '0 9 * * *' | |
| 5 | + - cron: "0 9 * * *" | |
| 6 | + workflow_dispatch: | |
| 6 | 7 | |
| 7 | 8 | jobs: |
| 8 | 9 | auto-close-duplicates: |
| @@ -10,69 +11,20 @@ | ||
| 10 | 11 | timeout-minutes: 10 |
| 11 | 12 | permissions: |
| 12 | 13 | contents: read |
| 13 | - issues: write | |
| 14 | + issues: read | |
| 14 | 15 | |
| 15 | 16 | steps: |
| 16 | - - name: Auto-close duplicate issues | |
| 17 | - uses: actions/github-script@v7 | |
| 17 | + - name: Checkout repository | |
| 18 | + uses: actions/checkout@v4 | |
| 19 | + | |
| 20 | + - name: Setup Bun | |
| 21 | + uses: oven-sh/setup-bun@v1 | |
| 18 | 22 | with: |
| 19 | - script: | | |
| 20 | - const threeDaysAgo = new Date(); | |
| 21 | - threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); | |
| 22 | - | |
| 23 | - // Get all open issues | |
| 24 | - const { data: issues } = await github.rest.issues.listForRepo({ | |
| 25 | - owner: context.repo.owner, | |
| 26 | - repo: context.repo.repo, | |
| 27 | - state: 'open', | |
| 28 | - per_page: 100 | |
| 29 | - }); | |
| 30 | - | |
| 31 | - for (const issue of issues) { | |
| 32 | - // Get comments for this issue | |
| 33 | - const { data: comments } = await github.rest.issues.listComments({ | |
| 34 | - owner: context.repo.owner, | |
| 35 | - repo: context.repo.repo, | |
| 36 | - issue_number: issue.number | |
| 37 | - }); | |
| 38 | - | |
| 39 | - // Find duplicate comments (look for "Found 1 possible duplicate" or "Found X possible duplicate") | |
| 40 | - const dupeComments = comments.filter(comment => | |
| 41 | - comment.body.includes('Found') && | |
| 42 | - comment.body.includes('possible duplicate') && | |
| 43 | - comment.user.type === 'Bot' | |
| 44 | - ); | |
| 45 | - | |
| 46 | - if (dupeComments.length === 0) continue; | |
| 47 | - | |
| 48 | - // Get the most recent duplicate comment | |
| 49 | - const lastDupeComment = dupeComments[dupeComments.length - 1]; | |
| 50 | - const dupeCommentDate = new Date(lastDupeComment.created_at); | |
| 51 | - | |
| 52 | - // Check if the duplicate comment is 3+ days old | |
| 53 | - if (dupeCommentDate > threeDaysAgo) continue; | |
| 54 | - | |
| 55 | - // Check if there are any comments after the duplicate comment | |
| 56 | - const commentsAfterDupe = comments.filter(comment => | |
| 57 | - new Date(comment.created_at) > dupeCommentDate | |
| 58 | - ); | |
| 59 | - | |
| 60 | - if (commentsAfterDupe.length > 0) continue; | |
| 61 | - | |
| 62 | - // Check if issue author reacted with thumbs down to the duplicate comment | |
| 63 | - const { data: reactions } = await github.rest.reactions.listForIssueComment({ | |
| 64 | - owner: context.repo.owner, | |
| 65 | - repo: context.repo.repo, | |
| 66 | - comment_id: lastDupeComment.id | |
| 67 | - }); | |
| 68 | - | |
| 69 | - const authorThumbsDown = reactions.some(reaction => | |
| 70 | - reaction.user.id === issue.user.id && reaction.content === '-1' | |
| 71 | - ); | |
| 72 | - | |
| 73 | - if (authorThumbsDown) continue; | |
| 74 | - | |
| 75 | - // DRY RUN: Log the issue that would be auto-closed | |
| 76 | - const issueUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${issue.number}`; | |
| 77 | - console.log(`[DRY RUN] Would auto-close issue #${issue.number} as duplicate: ${issueUrl}`); | |
| 78 | - } | |
| 23 | + bun-version: latest | |
| 24 | + | |
| 25 | + - name: Auto-close duplicate issues | |
| 26 | + run: bun run scripts/auto-close-duplicates.ts | |
| 27 | + env: | |
| 28 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 29 | + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} | |
| 30 | + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} |
| @@ -1,0 +1,183 @@ | ||
| 1 | +#!/usr/bin/env bun | |
| 2 | + | |
| 3 | +declare global { | |
| 4 | + var process: { | |
| 5 | + env: Record<string, string | undefined>; | |
| 6 | + }; | |
| 7 | +} | |
| 8 | + | |
| 9 | +interface GitHubIssue { | |
| 10 | + number: number; | |
| 11 | + title: string; | |
| 12 | + user: { id: number }; | |
| 13 | +} | |
| 14 | + | |
| 15 | +interface GitHubComment { | |
| 16 | + id: number; | |
| 17 | + body: string; | |
| 18 | + created_at: string; | |
| 19 | + user: { type: string; id: number }; | |
| 20 | +} | |
| 21 | + | |
| 22 | +interface GitHubReaction { | |
| 23 | + user: { id: number }; | |
| 24 | + content: string; | |
| 25 | +} | |
| 26 | + | |
| 27 | +async function githubRequest<T>(endpoint: string, token: string): Promise<T> { | |
| 28 | + const response = await fetch(`https://api.github.com${endpoint}`, { | |
| 29 | + headers: { | |
| 30 | + Authorization: `Bearer ${token}`, | |
| 31 | + Accept: "application/vnd.github.v3+json", | |
| 32 | + "User-Agent": "auto-close-duplicates-script", | |
| 33 | + }, | |
| 34 | + }); | |
| 35 | + | |
| 36 | + if (!response.ok) { | |
| 37 | + throw new Error( | |
| 38 | + `GitHub API request failed: ${response.status} ${response.statusText}` | |
| 39 | + ); | |
| 40 | + } | |
| 41 | + | |
| 42 | + return response.json(); | |
| 43 | +} | |
| 44 | + | |
| 45 | +async function autoCloseDuplicates(): Promise<void> { | |
| 46 | + console.log("[DEBUG] Starting auto-close duplicates script"); | |
| 47 | + | |
| 48 | + const token = process.env.GITHUB_TOKEN; | |
| 49 | + if (!token) { | |
| 50 | + throw new Error("GITHUB_TOKEN environment variable is required"); | |
| 51 | + } | |
| 52 | + console.log("[DEBUG] GitHub token found"); | |
| 53 | + | |
| 54 | + const owner = process.env.GITHUB_REPOSITORY_OWNER || "anthropics"; | |
| 55 | + const repo = process.env.GITHUB_REPOSITORY_NAME || "claude-code"; | |
| 56 | + console.log(`[DEBUG] Repository: ${owner}/${repo}`); | |
| 57 | + | |
| 58 | + const threeDaysAgo = new Date(); | |
| 59 | + threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); | |
| 60 | + console.log( | |
| 61 | + `[DEBUG] Checking for duplicate comments older than: ${threeDaysAgo.toISOString()}` | |
| 62 | + ); | |
| 63 | + | |
| 64 | + console.log("[DEBUG] Fetching open issues..."); | |
| 65 | + const issues: GitHubIssue[] = await githubRequest( | |
| 66 | + `/repos/${owner}/${repo}/issues?state=open&per_page=100`, | |
| 67 | + token | |
| 68 | + ); | |
| 69 | + console.log(`[DEBUG] Found ${issues.length} open issues`); | |
| 70 | + | |
| 71 | + let processedCount = 0; | |
| 72 | + let candidateCount = 0; | |
| 73 | + | |
| 74 | + for (const issue of issues) { | |
| 75 | + processedCount++; | |
| 76 | + console.log( | |
| 77 | + `[DEBUG] Processing issue #${issue.number} (${processedCount}/${issues.length}): ${issue.title}` | |
| 78 | + ); | |
| 79 | + | |
| 80 | + console.log(`[DEBUG] Fetching comments for issue #${issue.number}...`); | |
| 81 | + const comments: GitHubComment[] = await githubRequest( | |
| 82 | + `/repos/${owner}/${repo}/issues/${issue.number}/comments`, | |
| 83 | + token | |
| 84 | + ); | |
| 85 | + console.log( | |
| 86 | + `[DEBUG] Issue #${issue.number} has ${comments.length} comments` | |
| 87 | + ); | |
| 88 | + | |
| 89 | + const dupeComments = comments.filter( | |
| 90 | + (comment) => | |
| 91 | + comment.body.includes("Found") && | |
| 92 | + comment.body.includes("possible duplicate") && | |
| 93 | + comment.user.type === "Bot" | |
| 94 | + ); | |
| 95 | + console.log( | |
| 96 | + `[DEBUG] Issue #${issue.number} has ${dupeComments.length} duplicate detection comments` | |
| 97 | + ); | |
| 98 | + | |
| 99 | + if (dupeComments.length === 0) { | |
| 100 | + console.log( | |
| 101 | + `[DEBUG] Issue #${issue.number} - no duplicate comments found, skipping` | |
| 102 | + ); | |
| 103 | + continue; | |
| 104 | + } | |
| 105 | + | |
| 106 | + const lastDupeComment = dupeComments[dupeComments.length - 1]; | |
| 107 | + const dupeCommentDate = new Date(lastDupeComment.created_at); | |
| 108 | + console.log( | |
| 109 | + `[DEBUG] Issue #${ | |
| 110 | + issue.number | |
| 111 | + } - most recent duplicate comment from: ${dupeCommentDate.toISOString()}` | |
| 112 | + ); | |
| 113 | + | |
| 114 | + if (dupeCommentDate > threeDaysAgo) { | |
| 115 | + console.log( | |
| 116 | + `[DEBUG] Issue #${issue.number} - duplicate comment is too recent, skipping` | |
| 117 | + ); | |
| 118 | + continue; | |
| 119 | + } | |
| 120 | + console.log( | |
| 121 | + `[DEBUG] Issue #${ | |
| 122 | + issue.number | |
| 123 | + } - duplicate comment is old enough (${Math.floor( | |
| 124 | + (Date.now() - dupeCommentDate.getTime()) / (1000 * 60 * 60 * 24) | |
| 125 | + )} days)` | |
| 126 | + ); | |
| 127 | + | |
| 128 | + const commentsAfterDupe = comments.filter( | |
| 129 | + (comment) => new Date(comment.created_at) > dupeCommentDate | |
| 130 | + ); | |
| 131 | + console.log( | |
| 132 | + `[DEBUG] Issue #${issue.number} - ${commentsAfterDupe.length} comments after duplicate detection` | |
| 133 | + ); | |
| 134 | + | |
| 135 | + if (commentsAfterDupe.length > 0) { | |
| 136 | + console.log( | |
| 137 | + `[DEBUG] Issue #${issue.number} - has activity after duplicate comment, skipping` | |
| 138 | + ); | |
| 139 | + continue; | |
| 140 | + } | |
| 141 | + | |
| 142 | + console.log( | |
| 143 | + `[DEBUG] Issue #${issue.number} - checking reactions on duplicate comment...` | |
| 144 | + ); | |
| 145 | + const reactions: GitHubReaction[] = await githubRequest( | |
| 146 | + `/repos/${owner}/${repo}/issues/comments/${lastDupeComment.id}/reactions`, | |
| 147 | + token | |
| 148 | + ); | |
| 149 | + console.log( | |
| 150 | + `[DEBUG] Issue #${issue.number} - duplicate comment has ${reactions.length} reactions` | |
| 151 | + ); | |
| 152 | + | |
| 153 | + const authorThumbsDown = reactions.some( | |
| 154 | + (reaction) => | |
| 155 | + reaction.user.id === issue.user.id && reaction.content === "-1" | |
| 156 | + ); | |
| 157 | + console.log( | |
| 158 | + `[DEBUG] Issue #${issue.number} - author thumbs down reaction: ${authorThumbsDown}` | |
| 159 | + ); | |
| 160 | + | |
| 161 | + if (authorThumbsDown) { | |
| 162 | + console.log( | |
| 163 | + `[DEBUG] Issue #${issue.number} - author disagreed with duplicate detection, skipping` | |
| 164 | + ); | |
| 165 | + continue; | |
| 166 | + } | |
| 167 | + | |
| 168 | + candidateCount++; | |
| 169 | + const issueUrl = `https://github.com/${owner}/${repo}/issues/${issue.number}`; | |
| 170 | + console.log( | |
| 171 | + `[DRY RUN] Would auto-close issue #${issue.number} as duplicate: ${issueUrl}` | |
| 172 | + ); | |
| 173 | + } | |
| 174 | + | |
| 175 | + console.log( | |
| 176 | + `[DEBUG] Script completed. Processed ${processedCount} issues, found ${candidateCount} candidates for auto-close` | |
| 177 | + ); | |
| 178 | +} | |
| 179 | + | |
| 180 | +autoCloseDuplicates().catch(console.error); | |
| 181 | + | |
| 182 | +// Make it a module | |
| 183 | +export {}; |