Files
msd-core/scripts/issue-dedupe.cjs
Tom Boucher d32b8db635 feat(#836): no-LLM duplicate-issue detection + challenge + 1-day auto-close (#843)
* 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>
2026-06-07 20:35:43 -04:00

279 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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,
};