* feat(#836): no-LLM duplicate-issue detection + challenge + 1-day auto-close Adds a deterministic (no-LLM) duplicate-issue governance lifecycle: - scripts/issue-dedupe.cjs: pure, unit-tested module (tokenize, Sørensen–Dice title similarity, scoreCandidates, renderChallengeComment, shouldClose) with fail-safe destructive-action guards. - duplicate-check.yml (issues:opened): scores new-issue title against open issues, posts a challenge comment + applies the pending `possible-duplicate` label on a clear match. - duplicate-sweep.yml (daily cron): closes possible-duplicate issues whose challenge comment is >24h old with no human reply and no 👎 veto; honors exempt labels; re-checks the label immediately before close (TOCTOU guard); strips the label on close to avoid reopen loops. - remove-duplicate-label.yml (issue_comment:created): clears the label and applies needs-maintainer-review when any human responds. - bug_report.yml / docs_issue.yml: add the required "I searched existing issues" preflight checkbox so all five forms force a pre-search attestation. - docs/agents/triage-labels.md: document the label + lifecycle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#836): add changeset fragment for duplicate-issue detection Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
279 lines
9.4 KiB
JavaScript
279 lines
9.4 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const POSSIBLE_DUPLICATE_LABEL = 'possible-duplicate';
|
||
const HUMAN_REVIEW_LABEL = 'needs-maintainer-review';
|
||
const CHALLENGE_MARKER = '<!-- gsd-dedupe-challenge -->';
|
||
const DEFAULT_WINDOW_HOURS = 24;
|
||
const DEFAULT_THRESHOLD = 0.6;
|
||
const DEFAULT_MAX_CANDIDATES = 5;
|
||
const MIN_TOKEN_LENGTH = 3;
|
||
|
||
const EXEMPT_LABELS = [
|
||
'priority: critical',
|
||
'pinned',
|
||
'confirmed-bug',
|
||
'confirmed',
|
||
'fix-pending',
|
||
'needs-maintainer-review',
|
||
];
|
||
|
||
const STOPWORDS = new Set([
|
||
'the', 'a', 'an', 'and', 'or', 'but', 'if', 'then', 'is', 'are', 'was',
|
||
'be', 'to', 'of', 'in', 'on', 'for', 'with', 'as', 'at', 'by', 'from',
|
||
'this', 'that', 'it', 'its', 'not', 'no', 'when', 'what', 'why', 'how',
|
||
'does', 'do', 'doing', 'did', 'can', 'will', 'would', 'should',
|
||
'i', 'we', 'you', 'your', 'my', 'me',
|
||
'issue', 'bug', 'error', 'problem', 'feature', 'request',
|
||
'help', 'support', 'please', 'question',
|
||
'after', 'before', 'into', 'only', 'then', 'than', 'them', 'they',
|
||
'use', 'used', 'using',
|
||
]);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// tokenize(title) -> string[]
|
||
//
|
||
// Lowercase the title, replace any non-[a-z0-9] run with a space, split on
|
||
// whitespace, drop tokens shorter than MIN_TOKEN_LENGTH, drop STOPWORDS, and
|
||
// dedupe while preserving stable first-occurrence order.
|
||
//
|
||
// Non-string, null, or empty input returns []. Must not throw on any input.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function tokenize(title) {
|
||
if (typeof title !== 'string' || !title) return [];
|
||
|
||
const normalized = title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||
if (!normalized) return [];
|
||
|
||
const seen = new Set();
|
||
const result = [];
|
||
|
||
for (const token of normalized.split(' ')) {
|
||
if (!token || token.length < MIN_TOKEN_LENGTH) continue;
|
||
if (STOPWORDS.has(token)) continue;
|
||
if (seen.has(token)) continue;
|
||
seen.add(token);
|
||
result.push(token);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// diceSimilarity(aTokens, bTokens) -> number 0..1
|
||
//
|
||
// Sørensen–Dice over the token sets: 2 * |A ∩ B| / (|A| + |B|).
|
||
// Both inputs are treated as sets (duplicates ignored). Empty either side -> 0.
|
||
// Identical sets -> 1.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function diceSimilarity(aTokens, bTokens) {
|
||
const setA = new Set(aTokens);
|
||
const setB = new Set(bTokens);
|
||
|
||
if (setA.size === 0 || setB.size === 0) return 0;
|
||
|
||
let intersection = 0;
|
||
for (const token of setA) {
|
||
if (setB.has(token)) intersection += 1;
|
||
}
|
||
|
||
return (2 * intersection) / (setA.size + setB.size);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// scoreCandidates(newTitle, candidates, opts) -> [{number, title, score}]
|
||
//
|
||
// opts: { threshold=DEFAULT_THRESHOLD, limit=DEFAULT_MAX_CANDIDATES, excludeNumber }
|
||
//
|
||
// Tokenizes newTitle once. If no tokens -> []. Filters out null/garbage
|
||
// candidates, those missing a number, and the excluded number. Scores each
|
||
// using diceSimilarity. Keeps score >= threshold. Sorts DESC by score,
|
||
// tie-break ASC by number. Caps to limit.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function scoreCandidates(newTitle, candidates, opts) {
|
||
const threshold = (opts && opts.threshold != null) ? opts.threshold : DEFAULT_THRESHOLD;
|
||
const limit = (opts && opts.limit != null) ? opts.limit : DEFAULT_MAX_CANDIDATES;
|
||
const excludeNumber = opts && opts.excludeNumber;
|
||
|
||
const newTokens = tokenize(newTitle);
|
||
if (newTokens.length === 0) return [];
|
||
|
||
const scored = [];
|
||
|
||
const safeCandidates = Array.isArray(candidates) ? candidates : [];
|
||
for (const candidate of safeCandidates) {
|
||
if (!candidate || typeof candidate !== 'object') continue;
|
||
if (!(typeof candidate.number === 'number' && Number.isFinite(candidate.number))) continue;
|
||
if (candidate.number === excludeNumber) continue;
|
||
|
||
const score = diceSimilarity(newTokens, tokenize(candidate.title));
|
||
if (score < threshold) continue;
|
||
|
||
scored.push({ number: candidate.number, title: candidate.title, score });
|
||
}
|
||
|
||
scored.sort((a, b) => {
|
||
if (Math.abs(a.score - b.score) > 1e-9) return b.score - a.score;
|
||
return a.number - b.number;
|
||
});
|
||
|
||
return scored.slice(0, limit);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// renderChallengeComment(candidates, opts) -> string
|
||
//
|
||
// opts: { windowHours=DEFAULT_WINDOW_HOURS }
|
||
//
|
||
// Deterministic. Must start with CHALLENGE_MARKER on its own line. Must list
|
||
// each candidate as a line with #<number>, title, and percentage similarity.
|
||
// Must mention windowHours and the 👎 veto.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function renderChallengeComment(candidates, opts) {
|
||
const windowHours = (opts && opts.windowHours != null) ? opts.windowHours : DEFAULT_WINDOW_HOURS;
|
||
|
||
const lines = [CHALLENGE_MARKER, ''];
|
||
|
||
lines.push('**Possible duplicate detected.** This issue may already be reported:');
|
||
lines.push('');
|
||
|
||
for (const candidate of candidates) {
|
||
const pct = Math.round(candidate.score * 100);
|
||
lines.push(`- #${candidate.number} — ${candidate.title} (similarity ${pct}%)`);
|
||
}
|
||
|
||
lines.push('');
|
||
lines.push(
|
||
`If this is **not** a duplicate, react with 👎 on this comment to veto and keep the issue open. ` +
|
||
`If no response is received within ${windowHours} hours, this issue may be closed as a duplicate.`,
|
||
);
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// isChallengeComment(body) -> boolean
|
||
//
|
||
// True iff body is a string containing CHALLENGE_MARKER.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function isChallengeComment(body) {
|
||
if (typeof body !== 'string') return false;
|
||
return body.includes(CHALLENGE_MARKER);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// hasExemptLabel(labels) -> boolean
|
||
//
|
||
// labels may be an array of strings or array of {name}. True if any name is
|
||
// in EXEMPT_LABELS.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function hasExemptLabel(labels) {
|
||
if (!Array.isArray(labels)) return false;
|
||
const exemptSet = new Set(EXEMPT_LABELS);
|
||
for (const label of labels) {
|
||
const name = typeof label === 'string' ? label : (label && label.name);
|
||
if (name && exemptSet.has(name)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// toMs(value) -> number
|
||
//
|
||
// Coerce a Date, ISO string, or ms-number to milliseconds since epoch.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function toMs(value) {
|
||
if (value instanceof Date) return value.getTime();
|
||
if (typeof value === 'string') return new Date(value).getTime();
|
||
return Number(value);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// shouldClose(input) -> {close: boolean, reason: string}
|
||
//
|
||
// input: { now, labels, challengeComment, laterUserComments, windowHours=DEFAULT_WINDOW_HOURS }
|
||
//
|
||
// Decision order (returns first match):
|
||
// 1. hasExemptLabel(labels) -> {close:false, reason:'exempt-label'}
|
||
// 2. !challengeComment -> {close:false, reason:'no-challenge-comment'}
|
||
// 3. challengeComment.downvoted -> {close:false, reason:'vetoed'}
|
||
// 4. laterUserComments > 0 -> {close:false, reason:'reporter-responded'}
|
||
// 5. ageHours < windowHours -> {close:false, reason:'within-window'}
|
||
// 6. else -> {close:true, reason:'duplicate-no-response'}
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function shouldClose(input) {
|
||
const {
|
||
now,
|
||
labels = [],
|
||
challengeComment,
|
||
laterUserComments = 0,
|
||
} = input;
|
||
const windowHours = (input.windowHours != null) ? input.windowHours : DEFAULT_WINDOW_HOURS;
|
||
|
||
if (hasExemptLabel(labels)) {
|
||
return { close: false, reason: 'exempt-label' };
|
||
}
|
||
|
||
if (!challengeComment) {
|
||
return { close: false, reason: 'no-challenge-comment' };
|
||
}
|
||
|
||
if (challengeComment.downvoted) {
|
||
return { close: false, reason: 'vetoed' };
|
||
}
|
||
|
||
if (laterUserComments > 0) {
|
||
return { close: false, reason: 'reporter-responded' };
|
||
}
|
||
|
||
const nowMs = toMs(now);
|
||
const createdMs = toMs(challengeComment.createdAt);
|
||
|
||
if (!Number.isFinite(nowMs) || !Number.isFinite(createdMs)) {
|
||
return { close: false, reason: 'invalid-timestamp' };
|
||
}
|
||
|
||
const ageHours = (nowMs - createdMs) / 3600000;
|
||
|
||
if (ageHours < windowHours) {
|
||
return { close: false, reason: 'within-window' };
|
||
}
|
||
|
||
return { close: true, reason: 'duplicate-no-response' };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Exports
|
||
// ---------------------------------------------------------------------------
|
||
|
||
module.exports = {
|
||
POSSIBLE_DUPLICATE_LABEL,
|
||
HUMAN_REVIEW_LABEL,
|
||
CHALLENGE_MARKER,
|
||
DEFAULT_WINDOW_HOURS,
|
||
DEFAULT_THRESHOLD,
|
||
DEFAULT_MAX_CANDIDATES,
|
||
MIN_TOKEN_LENGTH,
|
||
EXEMPT_LABELS,
|
||
STOPWORDS,
|
||
tokenize,
|
||
diceSimilarity,
|
||
scoreCandidates,
|
||
renderChallengeComment,
|
||
isChallengeComment,
|
||
hasExemptLabel,
|
||
shouldClose,
|
||
};
|