Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit 93ab069

Browse files
authored
Merge pull request #1 from th30d4y/copilot/add-issue-pr-automation-bot
feat: add Issue and PR duplicate detection bot
2 parents 0395b94 + a1fcbae commit 93ab069

1 file changed

Lines changed: 174 additions & 0 deletions

File tree

.github/workflows/issue-pr-bot.yml

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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

Comments
 (0)