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>
This commit is contained in:
Tom Boucher
2026-06-07 20:35:43 -04:00
committed by GitHub
parent 988024c1a3
commit d32b8db635
9 changed files with 1117 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 843
---
Issues are now checked for duplicates when opened: a no-LLM title-similarity check posts a challenge comment and applies a `possible-duplicate` label when a new issue closely matches existing open ones. Flagged issues that go unanswered for 24h are auto-closed as duplicates (reply, or react 👎 to the bot comment, to keep one open); a reply clears the label and routes to `needs-maintainer-review`. (#836)

View File

@@ -13,6 +13,14 @@ body:
> 2. Redact usernames, paths, and API keys (e.g., replace `/Users/yourname/` with `/Users/REDACTED/`) > 2. Redact usernames, paths, and API keys (e.g., replace `/Users/yourname/` with `/Users/REDACTED/`)
> 3. Or run your logs through an anonymizer — we recommend **[presidio-anonymizer](https://microsoft.github.io/presidio/)** (open-source, local-only) or **[scrub](https://github.com/dssg/scrub)** before pasting > 3. Or run your logs through an anonymizer — we recommend **[presidio-anonymizer](https://microsoft.github.io/presidio/)** (open-source, local-only) or **[scrub](https://github.com/dssg/scrub)** before pasting
- type: checkboxes
id: preflight
attributes:
label: Pre-submission checklist
options:
- label: I have searched existing issues and this bug has not already been reported
required: true
- type: input - type: input
id: version id: version
attributes: attributes:

View File

@@ -8,6 +8,14 @@ body:
value: | value: |
Help us improve the docs. Point us to what's wrong or missing. Help us improve the docs. Point us to what's wrong or missing.
- type: checkboxes
id: preflight
attributes:
label: Pre-submission checklist
options:
- label: I have searched existing issues and this documentation problem has not already been reported
required: true
- type: dropdown - type: dropdown
id: type id: type
attributes: attributes:

54
.github/workflows/duplicate-check.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: Duplicate check
on:
issues:
types: [opened]
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
cancel-in-progress: true
permissions:
issues: write
contents: read
jobs:
detect:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const dedupe = require(`${process.env.GITHUB_WORKSPACE}/scripts/issue-dedupe.cjs`);
const issue = context.payload.issue;
if (issue.pull_request) return;
const existing = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
if (existing.includes(dedupe.POSSIBLE_DUPLICATE_LABEL)) return;
const open = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
const candidates = open
.filter((i) => !i.pull_request && i.number !== issue.number)
.map((i) => ({ number: i.number, title: i.title }));
const matches = dedupe.scoreCandidates(issue.title, candidates, { excludeNumber: issue.number });
if (!matches.length) {
core.info('No similar open issues found.');
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: dedupe.renderChallengeComment(matches, { windowHours: dedupe.DEFAULT_WINDOW_HOURS }),
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [dedupe.POSSIBLE_DUPLICATE_LABEL],
});
core.info(`Flagged #${issue.number} as possible duplicate of: ${matches.map((m) => '#' + m.number).join(', ')}`);

91
.github/workflows/duplicate-sweep.yml vendored Normal file
View File

@@ -0,0 +1,91 @@
name: Duplicate auto-close sweep
on:
schedule:
- cron: '0 7 * * *'
workflow_dispatch:
permissions:
issues: write
contents: read
jobs:
sweep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const dedupe = require(`${process.env.GITHUB_WORKSPACE}/scripts/issue-dedupe.cjs`);
const { owner, repo } = context.repo;
const issues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'open',
labels: dedupe.POSSIBLE_DUPLICATE_LABEL,
per_page: 100,
});
const now = Date.now();
for (const issue of issues) {
if (issue.pull_request) continue;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issue.number,
per_page: 100,
});
const challenges = comments.filter((c) => dedupe.isChallengeComment(c.body));
const challenge = challenges[challenges.length - 1];
let challengeComment = null;
let laterUserComments = 0;
if (challenge) {
const challengeAt = new Date(challenge.created_at).getTime();
laterUserComments = comments.filter(
(c) => new Date(c.created_at).getTime() > challengeAt && c.user && c.user.type !== 'Bot',
).length;
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
owner,
repo,
comment_id: challenge.id,
per_page: 100,
});
const downvoted = reactions.some((r) => r.content === '-1');
challengeComment = { createdAt: challenge.created_at, downvoted };
}
const decision = dedupe.shouldClose({
now,
labels: issue.labels,
challengeComment,
laterUserComments,
windowHours: dedupe.DEFAULT_WINDOW_HOURS,
});
core.info(`#${issue.number}: ${decision.reason}`);
if (!decision.close) continue;
const fresh = await github.rest.issues.get({ owner, repo, issue_number: issue.number });
if (fresh.data.state !== 'open') continue;
const freshLabels = (fresh.data.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
if (!freshLabels.includes(dedupe.POSSIBLE_DUPLICATE_LABEL)) {
core.info(`#${issue.number}: possible-duplicate cleared since snapshot, skipping close`);
continue;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: `Closing as a likely duplicate — no response within ${dedupe.DEFAULT_WINDOW_HOURS}h of the duplicate check. If this was a mistake, comment and a maintainer will reopen it.`,
});
await github.rest.issues.update({
owner,
repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'duplicate',
});
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: issue.number,
name: dedupe.POSSIBLE_DUPLICATE_LABEL,
}).catch((e) => core.info(`removeLabel after close: ${e.message}`));
}

View File

@@ -0,0 +1,39 @@
name: Clear possible-duplicate on response
on:
issue_comment:
types: [created]
permissions:
issues: write
contents: read
jobs:
clear:
if: ${{ !github.event.issue.pull_request }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const dedupe = require(`${process.env.GITHUB_WORKSPACE}/scripts/issue-dedupe.cjs`);
const { owner, repo } = context.repo;
const comment = context.payload.comment;
const issue = context.payload.issue;
if (comment.user && comment.user.type === 'Bot') return;
const existing = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
if (!existing.includes(dedupe.POSSIBLE_DUPLICATE_LABEL)) return;
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: issue.number,
name: dedupe.POSSIBLE_DUPLICATE_LABEL,
}).catch((e) => core.info(`removeLabel: ${e.message}`));
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issue.number,
labels: [dedupe.HUMAN_REVIEW_LABEL],
});
core.info(`Cleared possible-duplicate on #${issue.number}; routed to ${dedupe.HUMAN_REVIEW_LABEL}.`);

View File

@@ -9,6 +9,7 @@ Maps the five canonical triage roles to the actual label strings in `open-gsd/gs
| `ready-for-agent` | `confirmed` | Bug verified + fully specified — AFK agent can pick up | | `ready-for-agent` | `confirmed` | Bug verified + fully specified — AFK agent can pick up |
| `ready-for-human` | `approved-enhancement` / `approved-feature` | Enhancement/feature approved by maintainer — human codes it | | `ready-for-human` | `approved-enhancement` / `approved-feature` | Enhancement/feature approved by maintainer — human codes it |
| `wontfix` | `wontfix` | Will not be actioned | | `wontfix` | `wontfix` | Will not be actioned |
| `possible-duplicate` | `possible-duplicate` | Applied by the Duplicate check workflow when a new issue's title closely matches existing open issues. The reporter (or a maintainer) replies justifying why it is not a duplicate within 24h, or the Duplicate auto-close sweep closes it. A reply clears this label and applies needs-maintainer-review for human adjudication. React 👎 to the bot comment to veto auto-close. |
## Notes on this repo's label model ## Notes on this repo's label model
@@ -17,3 +18,12 @@ Maps the five canonical triage roles to the actual label strings in `open-gsd/gs
- There is no separate "ready-for-human" vs "ready-for-agent" distinction for enhancements — both flow through the same `approved-*` labels. If the work requires human judgment (design decisions, external access), note it in the issue body. - There is no separate "ready-for-human" vs "ready-for-agent" distinction for enhancements — both flow through the same `approved-*` labels. If the work requires human judgment (design decisions, external access), note it in the issue body.
- `needs-triage` is removed when any other state label is applied. - `needs-triage` is removed when any other state label is applied.
- `needs-reproduction` is used instead of the generic `needs-info` — be specific in triage comments about what reproduction steps or information are missing. - `needs-reproduction` is used instead of the generic `needs-info` — be specific in triage comments about what reproduction steps or information are missing.
## Duplicate detection lifecycle
The `possible-duplicate` label is managed by three GitHub Actions workflows that together form a self-service deduplication loop:
1. **Detect on open** — When an issue is opened, `duplicate-check.yml` scores its title against all other open issues using Dice-coefficient similarity. If any match clears the threshold, the bot posts a challenge comment listing the similar issues and applies `possible-duplicate`.
2. **Challenge comment + reporter window** — The reporter (or a maintainer) has `DEFAULT_WINDOW_HOURS` (24h) to reply explaining why the issue is not a duplicate. Reacting 👎 to the bot comment also signals the reporter objects to auto-close.
3. **Daily sweep auto-close** — `duplicate-sweep.yml` runs at 07:00 UTC daily. For each open issue with `possible-duplicate`, it checks whether the window has elapsed, whether the reporter replied, and whether a 👎 reaction exists. Issues with exempt labels (`priority: critical`, `pinned`, `confirmed-bug`, `confirmed`, `fix-pending`) are never auto-closed. Issues that pass the close check receive a closing comment and are closed with `state_reason: duplicate`.
4. **Reporter reply clears label** — `remove-duplicate-label.yml` fires on every new non-bot comment. If the issue still carries `possible-duplicate`, it removes that label and applies `needs-maintainer-review` (the value of `HUMAN_REVIEW_LABEL` in `scripts/issue-dedupe.cjs`), routing the issue to a maintainer for manual adjudication.

278
scripts/issue-dedupe.cjs Normal file
View File

@@ -0,0 +1,278 @@
#!/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,
};

624
tests/issue-dedupe.test.cjs Normal file
View File

@@ -0,0 +1,624 @@
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const {
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,
} = require('../scripts/issue-dedupe.cjs');
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
describe('issue-dedupe constants', () => {
test('POSSIBLE_DUPLICATE_LABEL is correct string', () => {
assert.equal(POSSIBLE_DUPLICATE_LABEL, 'possible-duplicate');
});
test('HUMAN_REVIEW_LABEL is correct string', () => {
assert.equal(HUMAN_REVIEW_LABEL, 'needs-maintainer-review');
});
test('CHALLENGE_MARKER is an HTML comment string', () => {
assert.ok(typeof CHALLENGE_MARKER === 'string');
assert.ok(CHALLENGE_MARKER.startsWith('<!--'));
assert.ok(CHALLENGE_MARKER.endsWith('-->'));
});
test('DEFAULT_WINDOW_HOURS is 24', () => {
assert.equal(DEFAULT_WINDOW_HOURS, 24);
});
test('DEFAULT_THRESHOLD is 0.6', () => {
assert.equal(DEFAULT_THRESHOLD, 0.6);
});
test('DEFAULT_MAX_CANDIDATES is 5', () => {
assert.equal(DEFAULT_MAX_CANDIDATES, 5);
});
test('MIN_TOKEN_LENGTH is 3', () => {
assert.equal(MIN_TOKEN_LENGTH, 3);
});
test('EXEMPT_LABELS includes required entries', () => {
assert.ok(Array.isArray(EXEMPT_LABELS));
for (const label of ['priority: critical', 'pinned', 'confirmed-bug', 'needs-maintainer-review']) {
assert.ok(EXEMPT_LABELS.includes(label), `EXEMPT_LABELS must contain "${label}"`);
}
});
test('STOPWORDS is a Set containing common words', () => {
assert.ok(STOPWORDS instanceof Set, 'STOPWORDS must be a Set');
for (const word of ['the', 'and', 'bug', 'issue', 'please']) {
assert.ok(STOPWORDS.has(word), `STOPWORDS must contain "${word}"`);
}
});
});
// ---------------------------------------------------------------------------
// tokenize
// ---------------------------------------------------------------------------
describe('tokenize', () => {
test('lowercases, strips punctuation, drops stopwords, dedupes', () => {
// 'CLI', 'crashes', '--flag', 'input' survive; 'Bug', 'on', 'with' are stopwords
const tokens = tokenize('Bug: CLI crashes on --flag with input!!!');
assert.ok(Array.isArray(tokens));
assert.ok(tokens.includes('cli'));
assert.ok(tokens.includes('crashes'));
assert.ok(tokens.includes('flag'));
assert.ok(tokens.includes('input'));
// stopwords must be absent
assert.ok(!tokens.includes('bug'));
assert.ok(!tokens.includes('on'));
assert.ok(!tokens.includes('with'));
// all lowercase
for (const t of tokens) assert.equal(t, t.toLowerCase());
});
test('drops tokens shorter than MIN_TOKEN_LENGTH', () => {
// 'ab' is 2 chars, 'xy' is 2 chars — both under MIN_TOKEN_LENGTH=3
const tokens = tokenize('ab xy hello');
assert.ok(!tokens.includes('ab'));
assert.ok(!tokens.includes('xy'));
assert.ok(tokens.includes('hello'));
});
test('deduplicates tokens (stable first-occurrence order)', () => {
const tokens = tokenize('crash crash crash happened happened');
assert.equal(tokens.filter((t) => t === 'crash').length, 1);
assert.equal(tokens.filter((t) => t === 'happened').length, 1);
// first occurrence order: crash before happened
assert.ok(tokens.indexOf('crash') < tokens.indexOf('happened'));
});
test('emoji-only title returns empty array and does not throw', () => {
const tokens = tokenize('🔥🔥');
assert.ok(Array.isArray(tokens));
assert.equal(tokens.length, 0);
});
test('empty string returns empty array', () => {
assert.deepEqual(tokenize(''), []);
});
test('null returns empty array', () => {
assert.deepEqual(tokenize(null), []);
});
test('non-string number returns empty array', () => {
assert.deepEqual(tokenize(123), []);
});
test('all-stopword title returns empty array', () => {
const tokens = tokenize('the and or but if');
assert.deepEqual(tokens, []);
});
test('complex real-world title with mixed punctuation and short words', () => {
const tokens = tokenize('Bug: CLI crashes on --flag with空 input!!!');
// non-ascii gets stripped along with punctuation — '空' becomes empty
// remaining meaningful tokens should be present
assert.ok(tokens.includes('cli'));
assert.ok(tokens.includes('crashes'));
assert.ok(tokens.includes('flag'));
assert.ok(tokens.includes('input'));
});
test('title with only very short words returns empty array', () => {
// 'to', 'be', 'or', 'it' are all either stopwords or too short (<3 chars)
const tokens = tokenize('to be or it');
assert.deepEqual(tokens, []);
});
});
// ---------------------------------------------------------------------------
// diceSimilarity
// ---------------------------------------------------------------------------
describe('diceSimilarity', () => {
test('identical token arrays return 1', () => {
assert.equal(diceSimilarity(['foo', 'bar', 'baz'], ['foo', 'bar', 'baz']), 1);
});
test('disjoint token arrays return 0', () => {
assert.equal(diceSimilarity(['foo', 'bar'], ['baz', 'qux']), 0);
});
test('partial overlap returns expected dice coefficient', () => {
// A = {a,b,c}, B = {b,c,d}: intersection = {b,c} -> 2*2/(3+3) = 4/6 ≈ 0.6667
const score = diceSimilarity(['a', 'b', 'c'], ['b', 'c', 'd']);
assert.ok(Math.abs(score - (4 / 6)) < 0.001, `Expected ~0.6667, got ${score}`);
});
test('empty first array returns 0', () => {
assert.equal(diceSimilarity([], ['foo', 'bar']), 0);
});
test('empty second array returns 0', () => {
assert.equal(diceSimilarity(['foo', 'bar'], []), 0);
});
test('both empty returns 0', () => {
assert.equal(diceSimilarity([], []), 0);
});
test('single common token: A={x}, B={x} -> 1', () => {
assert.equal(diceSimilarity(['x'], ['x']), 1);
});
test('single token each, different: 0', () => {
assert.equal(diceSimilarity(['x'], ['y']), 0);
});
test('treats token arrays as sets (duplicates in input do not inflate score)', () => {
// Even if caller somehow passes duplicates, score should still be well-formed
// A={foo,bar}, B={foo,bar}: expect 1 even with duplicates in input
const score = diceSimilarity(['foo', 'foo', 'bar'], ['foo', 'bar', 'bar']);
// The function works on sets internally; result should be 1
assert.equal(score, 1);
});
});
// ---------------------------------------------------------------------------
// scoreCandidates
// ---------------------------------------------------------------------------
describe('scoreCandidates', () => {
const candidates = [
{ number: 10, title: 'CLI crashes on startup with bad config' },
{ number: 11, title: 'Something entirely different here' },
{ number: 12, title: 'CLI crashes with segfault on startup' },
{ number: 13, title: 'Feature request add dark mode' },
];
test('excludeNumber excludes self from results', () => {
const results = scoreCandidates('CLI crashes on startup with bad config', candidates, { excludeNumber: 10 });
assert.ok(!results.some((r) => r.number === 10));
});
test('threshold filters out low-scoring candidates', () => {
const results = scoreCandidates('CLI crashes on startup', candidates, { threshold: 0.9 });
// Only very close matches should survive
for (const r of results) {
assert.ok(r.score >= 0.9, `Score ${r.score} for #${r.number} is below threshold`);
}
});
test('results are sorted descending by score, ascending by number on tie', () => {
const results = scoreCandidates('CLI crashes startup config', candidates, { threshold: 0 });
for (let i = 1; i < results.length; i++) {
const prev = results[i - 1];
const curr = results[i];
if (Math.abs(prev.score - curr.score) < 0.0001) {
// tie-break: ascending number
assert.ok(prev.number < curr.number, 'Tie-break should be ascending by number');
} else {
assert.ok(prev.score >= curr.score, 'Results should be sorted descending by score');
}
}
});
test('limit caps the number of results', () => {
const manyCandidates = Array.from({ length: 20 }, (_, i) => ({
number: i + 1,
title: `CLI crashes startup config issue ${i}`,
}));
const results = scoreCandidates('CLI crashes startup config', manyCandidates, { threshold: 0, limit: 3 });
assert.ok(results.length <= 3);
});
test('all-stopword newTitle returns empty array', () => {
const results = scoreCandidates('the and or but', candidates, { threshold: 0 });
assert.deepEqual(results, []);
});
test('null candidate entries are tolerated (filtered out)', () => {
const messyCandidates = [null, undefined, { number: 1, title: 'CLI crashes badly' }, { title: 'no number' }, { number: 2, title: null }];
let results;
assert.doesNotThrow(() => {
results = scoreCandidates('CLI crashes', messyCandidates, { threshold: 0 });
});
// entry without number should be skipped; entry with null title should survive (tokenize(null) = [])
assert.ok(!results.some((r) => r.number === undefined || r.number === null));
});
test('each result has number, title, and score fields', () => {
const results = scoreCandidates('CLI crashes on startup', candidates, { threshold: 0 });
for (const r of results) {
assert.ok('number' in r, 'result must have number');
assert.ok('title' in r, 'result must have title');
assert.ok('score' in r, 'result must have score');
assert.ok(typeof r.score === 'number');
assert.ok(r.score >= 0 && r.score <= 1);
}
});
test('defaults: threshold=DEFAULT_THRESHOLD, limit=DEFAULT_MAX_CANDIDATES', () => {
// With default opts, results should respect DEFAULT_THRESHOLD
const results = scoreCandidates('CLI crashes', candidates);
for (const r of results) {
assert.ok(r.score >= DEFAULT_THRESHOLD);
}
assert.ok(results.length <= DEFAULT_MAX_CANDIDATES);
});
test('candidate with missing number is skipped', () => {
const withNoNumber = [{ title: 'CLI crashes badly' }, { number: 5, title: 'CLI crashes badly' }];
const results = scoreCandidates('CLI crashes', withNoNumber, { threshold: 0 });
assert.ok(!results.some((r) => r.number === undefined));
assert.ok(results.some((r) => r.number === 5));
});
test('default threshold 0.6 excludes near-miss ~0.545, but explicit 0.5 includes it', () => {
// newTitle tokens: ['cli', 'crashes', 'startup'] (3 tokens — 'on' is stopword)
// candidate tokens: ['cli', 'crashes', 'startup', 'mode', 'display', 'render', 'timeout'] (7 tokens)
// intersection = 3, dice = 2*3/(3+7) = 6/10 = 0.6 exactly — adjust to get below 0.6
// newTitle tokens: ['crashes', 'startup'] (2 tokens after removing 'cli' via excludeNumber not applicable here)
// Use a 4-token new title and 7-token candidate with 3 shared for 6/11 ≈ 0.545
// newTitle: 'CLI crashes startup config' → tokens: ['cli','crashes','startup','config'] (4 tokens)
// candidate: 'CLI crashes startup mode display render timeout' → tokens: ['cli','crashes','startup','mode','display','render','timeout'] (7 tokens)
// intersection = {cli,crashes,startup} = 3; dice = 2*3/(4+7) = 6/11 ≈ 0.5454
const nearMissCandidate = [{ number: 99, title: 'CLI crashes startup mode display render timeout' }];
const scoreVal = 6 / 11; // ≈ 0.5454
assert.ok(scoreVal < 0.6, 'sanity: near-miss score is below 0.6');
assert.ok(scoreVal > 0.5, 'sanity: near-miss score is above 0.5');
// Default threshold (0.6) should exclude it
const withDefault = scoreCandidates('CLI crashes startup config', nearMissCandidate);
assert.equal(withDefault.length, 0, 'default threshold 0.6 must exclude ~0.545 score');
// Explicit threshold 0.5 should include it
const withLower = scoreCandidates('CLI crashes startup config', nearMissCandidate, { threshold: 0.5 });
assert.equal(withLower.length, 1, 'explicit threshold 0.5 must include ~0.545 score');
assert.ok(withLower[0].score > 0.5 && withLower[0].score < 0.6);
});
});
// ---------------------------------------------------------------------------
// renderChallengeComment
// ---------------------------------------------------------------------------
describe('renderChallengeComment', () => {
const sampleCandidates = [
{ number: 42, title: 'CLI crashes on startup', score: 0.83 },
{ number: 7, title: 'Segfault when starting CLI', score: 0.66 },
];
test('output starts with CHALLENGE_MARKER on its own line', () => {
const output = renderChallengeComment(sampleCandidates, {});
const firstLine = output.split('\n')[0];
assert.equal(firstLine.trim(), CHALLENGE_MARKER);
});
test('output contains each candidate number prefixed with #', () => {
const output = renderChallengeComment(sampleCandidates, {});
assert.ok(output.includes('#42'));
assert.ok(output.includes('#7'));
});
test('output contains each candidate title', () => {
const output = renderChallengeComment(sampleCandidates, {});
assert.ok(output.includes('CLI crashes on startup'));
assert.ok(output.includes('Segfault when starting CLI'));
});
test('output includes percentage similarity rounded correctly', () => {
const output = renderChallengeComment(sampleCandidates, {});
// 0.83 -> 83%, 0.66 -> 66%
assert.ok(output.includes('83%'), 'Expected 83% in output');
assert.ok(output.includes('66%'), 'Expected 66% in output');
});
test('output mentions the windowHours', () => {
const output = renderChallengeComment(sampleCandidates, { windowHours: 48 });
assert.ok(output.includes('48'), 'Output must mention windowHours=48');
});
test('output includes 👎 veto character', () => {
const output = renderChallengeComment(sampleCandidates, {});
assert.ok(output.includes('👎'), 'Output must include the 👎 veto emoji');
});
test('output is deterministic (same input produces same output)', () => {
const a = renderChallengeComment(sampleCandidates, {});
const b = renderChallengeComment(sampleCandidates, {});
assert.equal(a, b);
});
test('isChallengeComment(renderChallengeComment(...)) is true', () => {
const output = renderChallengeComment(sampleCandidates, {});
assert.equal(isChallengeComment(output), true);
});
test('uses DEFAULT_WINDOW_HOURS when windowHours not provided', () => {
const output = renderChallengeComment(sampleCandidates, {});
assert.ok(output.includes(String(DEFAULT_WINDOW_HOURS)));
});
});
// ---------------------------------------------------------------------------
// isChallengeComment
// ---------------------------------------------------------------------------
describe('isChallengeComment', () => {
test('returns true when body contains CHALLENGE_MARKER', () => {
assert.equal(isChallengeComment(`${CHALLENGE_MARKER}\nsome content`), true);
});
test('returns false when body does not contain CHALLENGE_MARKER', () => {
assert.equal(isChallengeComment('just a normal comment'), false);
});
test('returns false for empty string', () => {
assert.equal(isChallengeComment(''), false);
});
test('returns false for non-string', () => {
assert.equal(isChallengeComment(null), false);
assert.equal(isChallengeComment(undefined), false);
assert.equal(isChallengeComment(42), false);
});
});
// ---------------------------------------------------------------------------
// hasExemptLabel
// ---------------------------------------------------------------------------
describe('hasExemptLabel', () => {
test('returns true for string label matching an exempt label', () => {
assert.equal(hasExemptLabel(['priority: critical']), true);
});
test('returns true for object label {name} matching an exempt label', () => {
assert.equal(hasExemptLabel([{ name: 'pinned' }]), true);
});
test('returns true for confirmed-bug', () => {
assert.equal(hasExemptLabel(['confirmed-bug']), true);
});
test('returns true for needs-maintainer-review', () => {
assert.equal(hasExemptLabel(['needs-maintainer-review']), true);
});
test('returns false for non-exempt string label', () => {
assert.equal(hasExemptLabel(['bug']), false);
});
test('returns false for empty array', () => {
assert.equal(hasExemptLabel([]), false);
});
test('returns false for non-exempt object label', () => {
assert.equal(hasExemptLabel([{ name: 'enhancement' }]), false);
});
test('returns true when one of multiple labels is exempt', () => {
assert.equal(hasExemptLabel(['bug', 'priority: critical', 'enhancement']), true);
});
test('returns true for mixed string and object labels', () => {
assert.equal(hasExemptLabel(['bug', { name: 'pinned' }]), true);
});
});
// ---------------------------------------------------------------------------
// shouldClose
// ---------------------------------------------------------------------------
describe('shouldClose', () => {
const BASE_NOW = new Date('2026-01-01T12:00:00Z');
const CHALLENGE_CREATED_RECENT = new Date('2026-01-01T11:30:00Z'); // 30 min ago
const CHALLENGE_CREATED_OLD = new Date('2026-01-01T09:00:00Z'); // 3 hours ago, >DEFAULT
const CHALLENGE_CREATED_25H = new Date('2025-12-31T11:00:00Z'); // 25 hours ago
const baseChallenge = {
createdAt: CHALLENGE_CREATED_OLD,
downvoted: false,
};
test('exempt-label short-circuits even when overdue', () => {
const result = shouldClose({
now: BASE_NOW,
labels: ['priority: critical'],
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H },
laterUserComments: 0,
});
assert.equal(result.close, false);
assert.equal(result.reason, 'exempt-label');
});
test('no challenge comment returns no-challenge-comment', () => {
const result = shouldClose({
now: BASE_NOW,
labels: [],
challengeComment: null,
laterUserComments: 0,
});
assert.equal(result.close, false);
assert.equal(result.reason, 'no-challenge-comment');
});
test('no challenge comment with undefined challengeComment returns no-challenge-comment', () => {
const result = shouldClose({
now: BASE_NOW,
labels: [],
laterUserComments: 0,
});
assert.equal(result.close, false);
assert.equal(result.reason, 'no-challenge-comment');
});
test('downvoted challenge returns vetoed', () => {
const result = shouldClose({
now: BASE_NOW,
labels: [],
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H, downvoted: true },
laterUserComments: 0,
});
assert.equal(result.close, false);
assert.equal(result.reason, 'vetoed');
});
test('laterUserComments > 0 returns reporter-responded', () => {
const result = shouldClose({
now: BASE_NOW,
labels: [],
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H },
laterUserComments: 1,
});
assert.equal(result.close, false);
assert.equal(result.reason, 'reporter-responded');
});
test('within window (1h age, 24h window) returns within-window', () => {
const result = shouldClose({
now: BASE_NOW,
labels: [],
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_RECENT },
laterUserComments: 0,
});
assert.equal(result.close, false);
assert.equal(result.reason, 'within-window');
});
test('age > windowHours, no reply, not vetoed, not exempt -> close:true duplicate-no-response', () => {
const result = shouldClose({
now: BASE_NOW,
labels: [],
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H },
laterUserComments: 0,
});
assert.equal(result.close, true);
assert.equal(result.reason, 'duplicate-no-response');
});
test('createdAt as ISO string works', () => {
const result = shouldClose({
now: BASE_NOW,
labels: [],
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H.toISOString() },
laterUserComments: 0,
});
assert.equal(result.close, true);
assert.equal(result.reason, 'duplicate-no-response');
});
test('createdAt as Date object works', () => {
const result = shouldClose({
now: BASE_NOW,
labels: [],
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H },
laterUserComments: 0,
});
assert.equal(result.close, true);
assert.equal(result.reason, 'duplicate-no-response');
});
test('createdAt as ms-number works', () => {
const result = shouldClose({
now: BASE_NOW,
labels: [],
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H.getTime() },
laterUserComments: 0,
});
assert.equal(result.close, true);
assert.equal(result.reason, 'duplicate-no-response');
});
test('now as ms-number works', () => {
const result = shouldClose({
now: BASE_NOW.getTime(),
labels: [],
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H },
laterUserComments: 0,
});
assert.equal(result.close, true);
assert.equal(result.reason, 'duplicate-no-response');
});
test('custom windowHours is respected', () => {
// Challenge created 3 hours ago; with windowHours=1 it should be overdue
const threeHoursAgo = new Date(BASE_NOW.getTime() - 3 * 3600000);
const result = shouldClose({
now: BASE_NOW,
labels: [],
challengeComment: { ...baseChallenge, createdAt: threeHoursAgo },
laterUserComments: 0,
windowHours: 1,
});
assert.equal(result.close, true);
assert.equal(result.reason, 'duplicate-no-response');
});
test('exempt check still short-circuits when within window', () => {
const result = shouldClose({
now: BASE_NOW,
labels: ['confirmed'],
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_RECENT },
laterUserComments: 0,
});
assert.equal(result.close, false);
assert.equal(result.reason, 'exempt-label');
});
test('createdAt as unparseable string returns invalid-timestamp (fail-safe)', () => {
const result = shouldClose({
now: BASE_NOW,
labels: [],
challengeComment: { ...baseChallenge, createdAt: 'not-a-date' },
laterUserComments: 0,
});
assert.equal(result.close, false);
assert.equal(result.reason, 'invalid-timestamp');
});
test('now as NaN returns invalid-timestamp (fail-safe)', () => {
const result = shouldClose({
now: NaN,
labels: [],
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H },
laterUserComments: 0,
});
assert.equal(result.close, false);
assert.equal(result.reason, 'invalid-timestamp');
});
});