* 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:
5
.changeset/eager-birds-squeak.md
Normal file
5
.changeset/eager-birds-squeak.md
Normal 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)
|
||||||
8
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
8
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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:
|
||||||
|
|||||||
8
.github/ISSUE_TEMPLATE/docs_issue.yml
vendored
8
.github/ISSUE_TEMPLATE/docs_issue.yml
vendored
@@ -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
54
.github/workflows/duplicate-check.yml
vendored
Normal 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
91
.github/workflows/duplicate-sweep.yml
vendored
Normal 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}`));
|
||||||
|
}
|
||||||
39
.github/workflows/remove-duplicate-label.yml
vendored
Normal file
39
.github/workflows/remove-duplicate-label.yml
vendored
Normal 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}.`);
|
||||||
@@ -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
278
scripts/issue-dedupe.cjs
Normal 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
624
tests/issue-dedupe.test.cjs
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user