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