Add deterministic duplicate-issue detection and remove the broken similar-issues bot - #11678
Conversation
Implements the duplicate-detection portion of the issue-triage automation proposal (microsoft#11673) so every opened, edited, or reopened issue is checked against existing reports. - .github/scripts/issue-triage/find_duplicates.py: deterministic retrieval and scoring. Bounded GitHub search retrieval (max 30 candidates), weighted title/body/identifier similarity, max 5 suggestions. No writes; fails closed on retrieval errors so nothing is published. - .github/workflows/duplicate-issue-detection.yml: read-only detect job plus a separate issues:write publish job that re-checks issue state, maintains one canonical comment, and applies the possible-duplicate label. When no candidate clears the threshold, nothing is commented or labeled. - Unit tests plus a PR workflow that runs them. - Removes similar-issues-bot.yml, which is superseded; running both would post two bot comments per issue. Version and metadata form sections are excluded from scoring so unrelated reports on the same Windows build do not match. Issue text is treated as untrusted: markup is stripped before scoring and mentions and Markdown control characters are escaped before republishing. Third-party actions are pinned to full commit SHAs. Validated against the known duplicate cluster microsoft#11646-microsoft#11649: all three duplicates returned at high confidence from 30 candidates, and four unrelated open issues returned no candidates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The fork pilot hit HTTP 403 secondary rate limits when several issues were opened in quick succession: each issue issued up to 8 search requests against a 30-request-per-minute limit, and the previous fixed 5s/10s retry was too short to recover. - Pace search requests at one per 2.2s. - Derive the retry wait from Retry-After and x-ratelimit-reset headers, with exponential backoff as a fallback, capped at 75s. - Raise retry attempts from 3 to 4 and lower max queries per issue from 8 to 6. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Evaluated the detector offline against the 500 most recent upstream issues (124,750 scored pairs). The first run flagged 228 pairs, of which 218 (95.6%) came from a single tracking epic whose sub-tasks share a title prefix and a templated body. Two changes address that: - Strip structural title prefixes such as "[WinUI OSS] Phase 4:" before comparing titles. Epic sub-tasks share these as boilerplate, which inflated the title character ratio. 228 pairs -> 79. - Only run on issues carrying needs-triage, bug, or feature proposal. Both issue templates apply needs-triage; tracking sub-tasks carry no labels. 79 pairs -> 10. Also adds GitHub's native duplicate suggestion for High-confidence matches, adapted from microsoft/PowerToys. The PATCH uses suggest: true so GitHub renders an accept/decline chip and holds the close for human review instead of applying it. Because this is a preview API, the step verifies the response and reopens the issue if a close was actually applied by this request. Remove the label write entirely; the workflow now only comments and, for High confidence, submits the suggestion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Evaluation against 500 real issues, and two significant changesFollowing review feedback (thanks Niels Laute (@niels9001)) I evaluated the detector offline against the 500 most recent issues in this repository, scoring 124,750 ordered pairs. Each issue was compared only against issues that already existed when it was filed, mirroring what the bot sees when a new issue is opened. This used the REST API only, with no writes and no search API. The first run was bad, and it is worth showing why. Finding: 95.6% of matches were one tracking epic
The Two fixes:
Results after the fixes313 of the 500 issues pass the label gate. At the 0.62 default, 10 pairs across 7 issues (2.2% of the corpus) are flagged:
No false positives at any threshold from 0.55 up. These are corroborated by what maintainers already did: in four of the five clusters the redundant copies are closed while one remains open, and #11646 was closed with the comment "Since this is a duplicate ticket, we are closing it." The PipsPager pair (#11467 / #11469) is still open and undetected today, which is the case the bot is meant to catch. Threshold sweep on the gated corpus:
The only pair between 0.55 and 0.62 is Duplicate suggestion chipAdopted from the PowerToys issue-triage workflow. For High-confidence matches only, the workflow now submits GitHub's native duplicate suggestion so a maintainer can accept and close from the chip:
Verification status: in a fork test the PATCH was accepted and the issue correctly stayed open. However, neither the REST timeline nor GraphQL exposes a pending suggestion, so I could not confirm from the CLI that the chip actually renders. That likely needs the duplicate-detection preview enabled on the repository. Worth a visual check by someone with the preview enabled before relying on it; the comment path is unaffected either way. Also changed
End-to-endVerified in Jaylyn-Barbee/microsoft-ui-xaml: issue #9 received one comment ranking #7, #8, and #5 as High confidence, the suggestion was submitted against #7, and the issue remained open. Unlabeled issues are skipped by the gate as intended. |
Direct API probing showed that on a repository without the duplicate-detection
preview enabled, the suggestion is a silent no-op:
suggest: true -> HTTP 200, issue stays open, no chip, nothing recorded in
REST or GraphQL
suggest: false -> issue is closed with state_reason: duplicate
The payload is understood; only the suggestion behavior is gated behind the
preview. Leaving the step enabled would produce a green workflow run with no
effect, which is the success-shaped failure this design is meant to avoid.
The step now defaults to off and emits a warning that the chip cannot be
verified programmatically and must be checked visually the first time it runs.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pending duplicate suggestions are not exposed through REST, only through the GraphQL pendingSuggestions union on Issue. Reading the REST payload back made the write look like a silent no-op, which is why the step was previously disabled. Querying pendingSuggestions confirms the suggestion is recorded as a PendingCloseSuggestion with stateReason DUPLICATE. Re-enable the step and use that query to confirm the suggestion landed rather than trusting the HTTP 200, which is returned either way. Also query before writing so the workflow skips a redundant re-submit and defers when a human has already proposed a duplicate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fixes
Implements Phase 2 of #11673 (Proposal: issue triage and duplicate detection automation).
PR Type
.github/.Description
Replaces the repo's dead similar-issues bot with a deterministic duplicate detector that runs on every issue opened, edited, or reopened.
When confident duplicates are found, the workflow posts one canonical comment listing them, and — only when the top match is High confidence — submits GitHub's native duplicate suggestion, which renders the accept/decline agent suggestion chip at the top of the issue. When nothing clears the threshold, nothing is posted. Issues are never closed and no labels are ever applied or removed.
Current Behavior
.github/workflows/similar-issues-bot.ymlis stillactiveand still triggers on every new issue, but it has not worked in roughly 16 months:Every failing log ends with the same error:
craigloewen-msft/GitGudSimilarIssuesPOSTs the issue title and body to an external Azure App Service that no longer resolves. Becauseadd-commentis gated onneeds.getsimilarissues.outputs.message != '', the failure is silent — the job goes red, and no comment is ever posted.Two secondary concerns with the old bot: it shipped public issue text to a third-party endpoint, and it was pinned to a mutable
@mainref.New Behavior
Duplicates are detected in-repo, with no external service and no model in the loop. This PR deletes
similar-issues-bot.yml— keeping both would post two bot comments per issue.The chip is submitted with
suggest: true, so GitHub holds the close for maintainer review rather than applying it. The comment is intentionally the wider net: it is cheap and reversible, whereas the chip is a one-click close and should appear only when the evidence is strong.How Duplicates Are Detected
.github/workflows/duplicate-issue-detection.ymldetectjob, then a separateissues: writepublishjob.github/scripts/issue-triage/find_duplicates.py.github/scripts/issue-triage/tests/.github/workflows/issue-triage-tests.yml.github/scripts/issue-triage/README.mdScoring is deterministic and fully auditable:
0xerror codesVersion and metadata sections (
NuGet package version,Windows version,Screenshots,Additional context) are excluded from scoring, so two unrelated reports on the same Windows build do not score as similar. Retrieval is capped at 30 candidates and 6 search queries per issue; at most 5 suggestions are returned. Default threshold is0.62, configurable viaSIMILARITY_THRESHOLD.A label gate on the
detectjob restricts the workflow to issues carryingneeds-triage,bug, orfeature proposal. Both issue templates apply these at creation andblank_issues_enabled: false, so every user-filed issue qualifies; internal[WinUI OSS]tracking work does not. The gate is at job level, so excluded issues cost zero API calls.Safety
Aligned with the requirements in #11673:
publishis skipped, rather than producing a misleading "no duplicates found" result. This is the exact failure mode that made the old bot's breakage invisible.pendingSuggestionsunion, instead of trusting the HTTP status — the PATCH returns200either way. It defers entirely if a human has already proposed a duplicate, and if GitHub ever applied the close rather than suggesting it, the step reopens the issue and fails.cancel-in-progress: true.Customer Impact
Not user-facing in the product sense — no runtime, API, or XAML behavior changes. The impact is on issue reporters and maintainers: reporters get duplicates surfaced within a minute of filing instead of never, and maintainers get a one-click close on high-confidence matches.
The visible risk is a false positive: an incorrect comment or an incorrect chip. That risk is quantified below, and the chip never closes anything on its own.
Regression Potential
No product code is touched. The one behavioral change to the repo is that issues begin receiving duplicate comments again, which is the intended outcome — and which has been broken since April 2025, so there is no working behavior to regress.
How Has This Been Tested?
Unit tests. 40 tests in
.github/scripts/issue-triage/tests/, run in CI byissue-triage-tests.ymlon any PR touching these paths.Offline evaluation against the last 500 real issues. Every issue was scored against every older issue — 124,750 ordered pairs. This surfaced two significant false-positive sources, both fixed in this PR:
[WinUI OSS]epicTemplated epic sub-tasks share a title prefix, a body skeleton, and an identifier, and scored as near-identical to each other. After both fixes: 10 pairs across 7 issues (2.2% of the gated corpus), with zero false positives at any threshold at or above 0.55. All five clusters are genuine — French keyboard (#11646–#11649), PipsPager (#11467/#11469), ScrollView (#11039/#11040), AnimatedIcon (#10866/#10887), AOT custom control (#11065/#11248).
Two ground-truth checks: #11646 was closed by a maintainer with "Since this is a duplicate ticket, we are closing it." And #11467/#11469 is a real duplicate pair that is still open and unlinked today — the kind this would have caught at filing time.
End-to-end in a public fork (Jaylyn-Barbee/microsoft-ui-xaml), seeded with copies of real upstream issues:
Additional checks:
A duplicate suggestion pointing at #7 is already pending. Nothing to do.and the suggestion count stayed at 1.state_reason: duplicate, linked to the canonical issue.The pilot surfaced one real defect, fixed in the second commit: opening several issues in quick succession exhausted the search API's 30-requests-per-minute limit and returned HTTP 403. Requests are now paced, and retries honor
Retry-Afterandx-ratelimit-resetwith capped exponential backoff.Scoped Out
This is Phase 2 of #11673, narrowed to duplicate detection. Deliberately not included: model-based judgment,
area-*classification, reproduction-completeness analysis, and the maintainer digest.One known gap, left out as a separate decision: the workflow triggers on
opened,edited, andreopened, but notlabeled. An issue transferred in from another repo, or one a maintainer labels after the fact, is not re-scanned. Addinglabeledis a one-line change and safe given the idempotency above — happy to include it here if reviewers prefer.