|
| 1 | +name: Issue and PR Duplicate Detection Bot |
| 2 | + |
| 3 | +on: |
| 4 | + issues: |
| 5 | + types: [opened] |
| 6 | + pull_request: |
| 7 | + types: [opened] |
| 8 | + |
| 9 | +permissions: |
| 10 | + contents: read |
| 11 | + issues: write |
| 12 | + pull-requests: write |
| 13 | + |
| 14 | +jobs: |
| 15 | + duplicate-check: |
| 16 | + name: Check for Duplicates |
| 17 | + runs-on: ubuntu-latest |
| 18 | + |
| 19 | + steps: |
| 20 | + - name: Detect duplicates and triage |
| 21 | + uses: actions/github-script@v7 |
| 22 | + with: |
| 23 | + script: | |
| 24 | + const DUPLICATE_LABEL = 'duplicate'; |
| 25 | + const TRIAGE_LABEL = 'needs-triage'; |
| 26 | + // Minimum fraction of shared keywords to consider two items duplicates. |
| 27 | + const SIMILARITY_THRESHOLD = 0.3; |
| 28 | + // Stop words that carry no semantic meaning for matching. |
| 29 | + const STOP_WORDS = new Set([ |
| 30 | + 'a','an','the','and','or','but','in','on','at','to','for','of', |
| 31 | + 'with','is','are','was','were','be','been','being','have','has', |
| 32 | + 'had','do','does','did','will','would','could','should','may', |
| 33 | + 'might','shall','can','not','no','this','that','it','its', |
| 34 | + 'i','we','you','he','she','they','my','your','our','their', |
| 35 | + 'how','what','when','where','why','which','who','whom', |
| 36 | + ]); |
| 37 | +
|
| 38 | + const isPR = !!context.payload.pull_request; |
| 39 | + const item = isPR ? context.payload.pull_request : context.payload.issue; |
| 40 | + const itemNumber = item.number; |
| 41 | + const itemTitle = (item.title || '').toLowerCase(); |
| 42 | + const itemBody = (item.body || '').toLowerCase(); |
| 43 | + const itemText = `${itemTitle} ${itemBody}`; |
| 44 | +
|
| 45 | + // ── helpers ────────────────────────────────────────────────────── |
| 46 | + function tokenize(text) { |
| 47 | + return new Set( |
| 48 | + text |
| 49 | + .replace(/[^a-z0-9\s]/g, ' ') |
| 50 | + .split(/\s+/) |
| 51 | + .filter(w => w.length > 2 && !STOP_WORDS.has(w)) |
| 52 | + ); |
| 53 | + } |
| 54 | +
|
| 55 | + function jaccardSimilarity(setA, setB) { |
| 56 | + // Two empty token sets share no meaningful keywords — treat as dissimilar |
| 57 | + // so that blank issues are not flagged as duplicates of each other. |
| 58 | + if (setA.size === 0 || setB.size === 0) return 0; |
| 59 | + const intersection = [...setA].filter(w => setB.has(w)).length; |
| 60 | + const union = new Set([...setA, ...setB]).size; |
| 61 | + return intersection / union; |
| 62 | + } |
| 63 | +
|
| 64 | + // ── ensure required labels exist ───────────────────────────────── |
| 65 | + async function ensureLabel(name, color, description) { |
| 66 | + try { |
| 67 | + await github.rest.issues.getLabel({ |
| 68 | + owner: context.repo.owner, |
| 69 | + repo: context.repo.repo, |
| 70 | + name, |
| 71 | + }); |
| 72 | + } catch (err) { |
| 73 | + if (err.status === 404) { |
| 74 | + await github.rest.issues.createLabel({ |
| 75 | + owner: context.repo.owner, |
| 76 | + repo: context.repo.repo, |
| 77 | + name, |
| 78 | + color, |
| 79 | + description, |
| 80 | + }); |
| 81 | + } else { |
| 82 | + throw err; |
| 83 | + } |
| 84 | + } |
| 85 | + } |
| 86 | +
|
| 87 | + await ensureLabel(DUPLICATE_LABEL, 'cfd3d7', 'This issue or PR is a duplicate of another'); |
| 88 | + await ensureLabel(TRIAGE_LABEL, 'e4e669', 'Needs triage by a maintainer'); |
| 89 | +
|
| 90 | + // ── fetch existing open issues (excluding the current one) ─────── |
| 91 | + const existingItems = await github.paginate( |
| 92 | + github.rest.issues.listForRepo, |
| 93 | + { |
| 94 | + owner: context.repo.owner, |
| 95 | + repo: context.repo.repo, |
| 96 | + state: 'open', |
| 97 | + per_page: 100, |
| 98 | + } |
| 99 | + ); |
| 100 | +
|
| 101 | + const candidates = existingItems.filter(i => i.number !== itemNumber); |
| 102 | +
|
| 103 | + // ── compare against each candidate ─────────────────────────────── |
| 104 | + const newTokens = tokenize(itemText); |
| 105 | + let bestMatch = null; |
| 106 | + let bestScore = 0; |
| 107 | +
|
| 108 | + for (const candidate of candidates) { |
| 109 | + const candidateText = `${(candidate.title || '').toLowerCase()} ${(candidate.body || '').toLowerCase()}`; |
| 110 | + const candidateTokens = tokenize(candidateText); |
| 111 | + const score = jaccardSimilarity(newTokens, candidateTokens); |
| 112 | +
|
| 113 | + if (score > bestScore) { |
| 114 | + bestScore = score; |
| 115 | + bestMatch = candidate; |
| 116 | + } |
| 117 | + } |
| 118 | +
|
| 119 | + const isDuplicate = bestScore >= SIMILARITY_THRESHOLD; |
| 120 | + const itemType = isPR ? 'Pull Request' : 'Issue'; |
| 121 | +
|
| 122 | + // ── act on result ──────────────────────────────────────────────── |
| 123 | + if (isDuplicate) { |
| 124 | + core.info(`Duplicate detected: #${bestMatch.number} (score ${bestScore.toFixed(3)})`); |
| 125 | +
|
| 126 | + // Add 'duplicate' label |
| 127 | + await github.rest.issues.addLabels({ |
| 128 | + owner: context.repo.owner, |
| 129 | + repo: context.repo.repo, |
| 130 | + issue_number: itemNumber, |
| 131 | + labels: [DUPLICATE_LABEL], |
| 132 | + }); |
| 133 | +
|
| 134 | + // Post duplicate comment |
| 135 | + await github.rest.issues.createComment({ |
| 136 | + owner: context.repo.owner, |
| 137 | + repo: context.repo.repo, |
| 138 | + issue_number: itemNumber, |
| 139 | + body: [ |
| 140 | + `⚠️ This looks like a duplicate of #${bestMatch.number}.`, |
| 141 | + '', |
| 142 | + `Please check that ${bestMatch.pull_request ? 'PR' : 'issue'} for updates and continue the discussion there.`, |
| 143 | + 'If you believe this is **not** a duplicate, please leave a comment explaining the difference and a maintainer will re-triage.', |
| 144 | + ].join('\n'), |
| 145 | + }); |
| 146 | +
|
| 147 | + } else { |
| 148 | + core.info(`No duplicate found (best score ${bestScore.toFixed(3)})`); |
| 149 | +
|
| 150 | + // Add 'needs-triage' label |
| 151 | + await github.rest.issues.addLabels({ |
| 152 | + owner: context.repo.owner, |
| 153 | + repo: context.repo.repo, |
| 154 | + issue_number: itemNumber, |
| 155 | + labels: [TRIAGE_LABEL], |
| 156 | + }); |
| 157 | +
|
| 158 | + // Post welcome / acknowledgement comment |
| 159 | + const welcomeLines = [ |
| 160 | + `👋 Thanks for opening this ${itemType}!`, |
| 161 | + '', |
| 162 | + 'A maintainer will review it shortly. In the meantime:', |
| 163 | + '', |
| 164 | + '- Please make sure your description is as detailed as possible.', |
| 165 | + '- Search existing issues at https://github.com/' + context.repo.owner + '/' + context.repo.repo + '/issues to avoid duplicates.', |
| 166 | + '- Follow the contribution guidelines if they apply.', |
| 167 | + ]; |
| 168 | + await github.rest.issues.createComment({ |
| 169 | + owner: context.repo.owner, |
| 170 | + repo: context.repo.repo, |
| 171 | + issue_number: itemNumber, |
| 172 | + body: welcomeLines.join('\n'), |
| 173 | + }); |
| 174 | + } |
0 commit comments