|
| 1 | +const github = require('@actions/github'); |
| 2 | +const { GoogleGenerativeAI } = require('@google/generative-ai'); |
| 3 | + |
| 4 | +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); |
| 5 | + |
| 6 | +function cosineSimilarity(vecA, vecB) { |
| 7 | + let dotProduct = 0.0; |
| 8 | + let normA = 0.0; |
| 9 | + let normB = 0.0; |
| 10 | + for (let i = 0; i < vecA.length; i++) { |
| 11 | + dotProduct += vecA[i] * vecB[i]; |
| 12 | + normA += vecA[i] * vecA[i]; |
| 13 | + normB += vecB[i] * vecB[i]; |
| 14 | + } |
| 15 | + if (normA === 0 || normB === 0) return 0; |
| 16 | + return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); |
| 17 | +} |
| 18 | + |
| 19 | +async function run() { |
| 20 | + const geminiApiKey = process.env.GEMINI_API_KEY; |
| 21 | + const githubToken = process.env.GITHUB_TOKEN; |
| 22 | + |
| 23 | + if (!geminiApiKey) { |
| 24 | + throw new Error('❌ Missing GEMINI_API_KEY environment variable.'); |
| 25 | + } |
| 26 | + if (!githubToken) { |
| 27 | + throw new Error('❌ Missing GITHUB_TOKEN environment variable.'); |
| 28 | + } |
| 29 | + |
| 30 | + const octokit = github.getOctokit(githubToken); |
| 31 | + const { owner, repo } = github.context.repo; |
| 32 | + console.log(`🤖 Starting semantic scan for duplicates in ${owner}/${repo}...`); |
| 33 | + |
| 34 | + // 1. Fetch all open issues in the repository |
| 35 | + console.log('Fetching all open issues...'); |
| 36 | + const allIssues = await octokit.paginate(octokit.rest.issues.listForRepo, { |
| 37 | + owner, |
| 38 | + repo, |
| 39 | + state: 'open', |
| 40 | + per_page: 100, |
| 41 | + }); |
| 42 | + |
| 43 | + // Filter out Pull Requests |
| 44 | + const openIssues = allIssues.filter((issue) => !issue.pull_request); |
| 45 | + console.log(`Found ${openIssues.length} open issues (excluding Pull Requests).`); |
| 46 | + |
| 47 | + if (openIssues.length < 2) { |
| 48 | + console.log('ℹ️ Not enough issues to compare. Ending scan.'); |
| 49 | + return; |
| 50 | + } |
| 51 | + |
| 52 | + // 2. Initialize Gemini API |
| 53 | + const genAI = new GoogleGenerativeAI(geminiApiKey); |
| 54 | + const model = genAI.getGenerativeModel({ model: 'gemini-embedding-001' }); |
| 55 | + |
| 56 | + // 3. Generate embeddings with 4.1s rate limit delay |
| 57 | + console.log('Generating semantic embeddings using gemini-embedding-001...'); |
| 58 | + for (let i = 0; i < openIssues.length; i++) { |
| 59 | + const issue = openIssues[i]; |
| 60 | + const textToEmbed = `Title: ${issue.title}\nBody: ${issue.body || ''}`.slice(0, 3000); |
| 61 | + |
| 62 | + console.log( |
| 63 | + `[${i + 1}/${openIssues.length}] Embedding Issue #${issue.number}: "${issue.title.substring(0, 50)}..."` |
| 64 | + ); |
| 65 | + |
| 66 | + try { |
| 67 | + const result = await model.embedContent(textToEmbed); |
| 68 | + issue.embedding = result.embedding.values; |
| 69 | + } catch (err) { |
| 70 | + console.error(`❌ Failed to generate embedding for Issue #${issue.number}:`, err.message); |
| 71 | + throw err; |
| 72 | + } |
| 73 | + |
| 74 | + if (i < openIssues.length - 1) { |
| 75 | + console.log('Sleeping for 4.1 seconds to avoid rate limits...'); |
| 76 | + await delay(4100); |
| 77 | + } |
| 78 | + } |
| 79 | + |
| 80 | + // 4. Perform pairwise comparisons |
| 81 | + console.log('\nComparing issues for semantic similarity...'); |
| 82 | + let duplicatesCount = 0; |
| 83 | + |
| 84 | + for (let i = 0; i < openIssues.length; i++) { |
| 85 | + for (let j = i + 1; j < openIssues.length; j++) { |
| 86 | + const issueA = openIssues[i]; |
| 87 | + const issueB = openIssues[j]; |
| 88 | + |
| 89 | + const similarity = cosineSimilarity(issueA.embedding, issueB.embedding); |
| 90 | + |
| 91 | + // Flag if similarity is 85% or higher |
| 92 | + if (similarity >= 0.85) { |
| 93 | + const newerIssue = issueA.number > issueB.number ? issueA : issueB; |
| 94 | + const olderIssue = issueA.number < issueB.number ? issueA : issueB; |
| 95 | + const similarityPercent = (similarity * 100).toFixed(1); |
| 96 | + |
| 97 | + console.log( |
| 98 | + `⚠️ Possible Duplicate: #${newerIssue.number} and #${olderIssue.number} similarity: ${similarityPercent}%` |
| 99 | + ); |
| 100 | + |
| 101 | + // Check if bot already commented on the newer issue |
| 102 | + console.log(`Checking comments on newer issue #${newerIssue.number}...`); |
| 103 | + const { data: comments } = await octokit.rest.issues.listComments({ |
| 104 | + owner, |
| 105 | + repo, |
| 106 | + issue_number: newerIssue.number, |
| 107 | + }); |
| 108 | + |
| 109 | + const alreadyFlagged = comments.some( |
| 110 | + (comment) => |
| 111 | + comment.body && |
| 112 | + comment.body.includes( |
| 113 | + `My semantic scan detected that this issue might be a duplicate of #${olderIssue.number}` |
| 114 | + ) |
| 115 | + ); |
| 116 | + |
| 117 | + if (alreadyFlagged) { |
| 118 | + console.log( |
| 119 | + `ℹ️ Already flagged Issue #${newerIssue.number} as duplicate of #${olderIssue.number}. Skipping comment.` |
| 120 | + ); |
| 121 | + continue; |
| 122 | + } |
| 123 | + |
| 124 | + // Post duplicate warning comment |
| 125 | + const author = newerIssue.user.login; |
| 126 | + const commentBody = `Hey @${author}! 🤖\n\nMy semantic scan detected that this issue might be a duplicate of #${olderIssue.number} (Similarity: **${similarityPercent}%**).\n\nPlease check between these issues and close this one if it is a duplicate.`; |
| 127 | + |
| 128 | + console.log(`Posting duplicate warning comment on newer issue #${newerIssue.number}...`); |
| 129 | + await octokit.rest.issues.createComment({ |
| 130 | + owner, |
| 131 | + repo, |
| 132 | + issue_number: newerIssue.number, |
| 133 | + body: commentBody, |
| 134 | + }); |
| 135 | + |
| 136 | + // Add 'possible-duplicate' label to the newer issue |
| 137 | + try { |
| 138 | + console.log(`Adding 'possible-duplicate' label to issue #${newerIssue.number}...`); |
| 139 | + await octokit.rest.issues.addLabels({ |
| 140 | + owner, |
| 141 | + repo, |
| 142 | + issue_number: newerIssue.number, |
| 143 | + labels: ['possible-duplicate'], |
| 144 | + }); |
| 145 | + } catch (labelErr) { |
| 146 | + console.warn( |
| 147 | + `⚠️ Warning: Could not add label 'possible-duplicate' to Issue #${newerIssue.number}:`, |
| 148 | + labelErr.message |
| 149 | + ); |
| 150 | + } |
| 151 | + |
| 152 | + duplicatesCount++; |
| 153 | + } |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + console.log(`\n🎉 Semantic duplicate scan complete! Flagged ${duplicatesCount} new duplicates.`); |
| 158 | +} |
| 159 | + |
| 160 | +run().catch((error) => { |
| 161 | + console.error('❌ Execution failed:', error.message); |
| 162 | + process.exit(1); |
| 163 | +}); |
0 commit comments