diff --git a/.changeset/eager-birds-squeak.md b/.changeset/eager-birds-squeak.md new file mode 100644 index 000000000..1963ad041 --- /dev/null +++ b/.changeset/eager-birds-squeak.md @@ -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) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c1623c685..f1bfa3727 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,6 +13,14 @@ body: > 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 + - 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 id: version attributes: diff --git a/.github/ISSUE_TEMPLATE/docs_issue.yml b/.github/ISSUE_TEMPLATE/docs_issue.yml index b40577b30..ce1e324a4 100644 --- a/.github/ISSUE_TEMPLATE/docs_issue.yml +++ b/.github/ISSUE_TEMPLATE/docs_issue.yml @@ -8,6 +8,14 @@ body: value: | 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 id: type attributes: diff --git a/.github/workflows/duplicate-check.yml b/.github/workflows/duplicate-check.yml new file mode 100644 index 000000000..0931c4575 --- /dev/null +++ b/.github/workflows/duplicate-check.yml @@ -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(', ')}`); diff --git a/.github/workflows/duplicate-sweep.yml b/.github/workflows/duplicate-sweep.yml new file mode 100644 index 000000000..083c32118 --- /dev/null +++ b/.github/workflows/duplicate-sweep.yml @@ -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}`)); + } diff --git a/.github/workflows/remove-duplicate-label.yml b/.github/workflows/remove-duplicate-label.yml new file mode 100644 index 000000000..af71abd9e --- /dev/null +++ b/.github/workflows/remove-duplicate-label.yml @@ -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}.`); diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md index 04b40b648..a78db6082 100644 --- a/docs/agents/triage-labels.md +++ b/docs/agents/triage-labels.md @@ -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-human` | `approved-enhancement` / `approved-feature` | Enhancement/feature approved by maintainer β€” human codes it | | `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 @@ -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. - `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. + +## 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. diff --git a/scripts/issue-dedupe.cjs b/scripts/issue-dedupe.cjs new file mode 100644 index 000000000..fc2204bd8 --- /dev/null +++ b/scripts/issue-dedupe.cjs @@ -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 = ''; +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 #, 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, +}; diff --git a/tests/issue-dedupe.test.cjs b/tests/issue-dedupe.test.cjs new file mode 100644 index 000000000..eb4695a9b --- /dev/null +++ b/tests/issue-dedupe.test.cjs @@ -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('')); + }); + + 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'); + }); +});