@miketromba/issy-core 0.5.6 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/search.ts CHANGED
@@ -8,22 +8,22 @@ import type { Issue, IssueFilters } from './types'
8
8
 
9
9
  // Fuse.js configuration for fuzzy search
10
10
  const FUSE_OPTIONS: IFuseOptions<Issue> = {
11
- keys: [
12
- { name: 'frontmatter.title', weight: 1.0 },
13
- { name: 'frontmatter.description', weight: 0.7 },
14
- { name: 'frontmatter.labels', weight: 0.5 },
15
- { name: 'content', weight: 0.3 },
16
- ],
17
- threshold: 0.4, // 0 = exact match, 1 = match anything
18
- ignoreLocation: true, // search entire string, not just beginning
19
- includeScore: true,
11
+ keys: [
12
+ { name: 'frontmatter.title', weight: 1.0 },
13
+ { name: 'frontmatter.description', weight: 0.7 },
14
+ { name: 'frontmatter.labels', weight: 0.5 },
15
+ { name: 'content', weight: 0.3 }
16
+ ],
17
+ threshold: 0.4, // 0 = exact match, 1 = match anything
18
+ ignoreLocation: true, // search entire string, not just beginning
19
+ includeScore: true
20
20
  }
21
21
 
22
22
  /**
23
23
  * Create a Fuse.js instance for searching issues
24
24
  */
25
25
  export function createSearchIndex(issues: Issue[]): Fuse<Issue> {
26
- return new Fuse(issues, FUSE_OPTIONS)
26
+ return new Fuse(issues, FUSE_OPTIONS)
27
27
  }
28
28
 
29
29
  /**
@@ -31,33 +31,36 @@ export function createSearchIndex(issues: Issue[]): Fuse<Issue> {
31
31
  * Returns issues sorted by relevance
32
32
  */
33
33
  export function searchIssues(fuse: Fuse<Issue>, query: string): Issue[] {
34
- if (!query.trim()) {
35
- return []
36
- }
34
+ if (!query.trim()) {
35
+ return []
36
+ }
37
37
 
38
- const results = fuse.search(query)
39
- return results.map((r) => r.item)
38
+ const results = fuse.search(query)
39
+ return results.map(r => r.item)
40
40
  }
41
41
 
42
42
  /**
43
43
  * Filter issues by frontmatter fields
44
44
  */
45
45
  export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] {
46
- return issues.filter((issue) => {
47
- if (filters.status && issue.frontmatter.status !== filters.status) {
48
- return false
49
- }
50
- if (filters.priority && issue.frontmatter.priority !== filters.priority) {
51
- return false
52
- }
53
- if (filters.scope && issue.frontmatter.scope !== filters.scope) {
54
- return false
55
- }
56
- if (filters.type && issue.frontmatter.type !== filters.type) {
57
- return false
58
- }
59
- return true
60
- })
46
+ return issues.filter(issue => {
47
+ if (filters.status && issue.frontmatter.status !== filters.status) {
48
+ return false
49
+ }
50
+ if (
51
+ filters.priority &&
52
+ issue.frontmatter.priority !== filters.priority
53
+ ) {
54
+ return false
55
+ }
56
+ if (filters.scope && issue.frontmatter.scope !== filters.scope) {
57
+ return false
58
+ }
59
+ if (filters.type && issue.frontmatter.type !== filters.type) {
60
+ return false
61
+ }
62
+ return true
63
+ })
61
64
  }
62
65
 
63
66
  /**
@@ -66,58 +69,60 @@ export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] {
66
69
  * ID matches (exact prefix) are ranked first
67
70
  */
68
71
  export function filterAndSearchIssues(
69
- issues: Issue[],
70
- filters: IssueFilters,
72
+ issues: Issue[],
73
+ filters: IssueFilters
71
74
  ): Issue[] {
72
- // First apply dropdown filters
73
- let result = filterIssues(issues, filters)
74
-
75
- // Then apply fuzzy search if there's a search term
76
- if (filters.search?.trim()) {
77
- const query = filters.search.trim()
78
-
79
- // Check for ID matches first (exact prefix match)
80
- // Supports: "1" -> "0001", "01" -> "0001", "0001" -> "0001"
81
- const idMatches: Issue[] = []
82
- const nonIdMatches: Issue[] = []
83
-
84
- const normalizedQuery = query.replace(/^0+/, '') // Remove leading zeros
85
-
86
- for (const issue of result) {
87
- const normalizedId = issue.id.replace(/^0+/, '')
88
- if (
89
- normalizedId.startsWith(normalizedQuery) ||
90
- issue.id.startsWith(query)
91
- ) {
92
- idMatches.push(issue)
93
- }
94
- }
95
-
96
- // Now do fuzzy search
97
- const fuse = createSearchIndex(issues)
98
- const searchResults = fuse.search(query)
99
- const matchedIds = new Set(searchResults.map((r) => r.item.id))
100
-
101
- // Get fuzzy matches that aren't already ID matches
102
- const idMatchSet = new Set(idMatches.map((i) => i.id))
103
- for (const issue of result) {
104
- if (!idMatchSet.has(issue.id) && matchedIds.has(issue.id)) {
105
- nonIdMatches.push(issue)
106
- }
107
- }
108
-
109
- // Sort fuzzy matches by relevance
110
- nonIdMatches.sort((a, b) => {
111
- const aScore = searchResults.find((r) => r.item.id === a.id)?.score ?? 1
112
- const bScore = searchResults.find((r) => r.item.id === b.id)?.score ?? 1
113
- return aScore - bScore // Lower score = better match
114
- })
115
-
116
- // ID matches first, then fuzzy matches
117
- result = [...idMatches, ...nonIdMatches]
118
- }
119
-
120
- return result
75
+ // First apply dropdown filters
76
+ let result = filterIssues(issues, filters)
77
+
78
+ // Then apply fuzzy search if there's a search term
79
+ if (filters.search?.trim()) {
80
+ const query = filters.search.trim()
81
+
82
+ // Check for ID matches first (exact prefix match)
83
+ // Supports: "1" -> "0001", "01" -> "0001", "0001" -> "0001"
84
+ const idMatches: Issue[] = []
85
+ const nonIdMatches: Issue[] = []
86
+
87
+ const normalizedQuery = query.replace(/^0+/, '') // Remove leading zeros
88
+
89
+ for (const issue of result) {
90
+ const normalizedId = issue.id.replace(/^0+/, '')
91
+ if (
92
+ normalizedId.startsWith(normalizedQuery) ||
93
+ issue.id.startsWith(query)
94
+ ) {
95
+ idMatches.push(issue)
96
+ }
97
+ }
98
+
99
+ // Now do fuzzy search
100
+ const fuse = createSearchIndex(issues)
101
+ const searchResults = fuse.search(query)
102
+ const matchedIds = new Set(searchResults.map(r => r.item.id))
103
+
104
+ // Get fuzzy matches that aren't already ID matches
105
+ const idMatchSet = new Set(idMatches.map(i => i.id))
106
+ for (const issue of result) {
107
+ if (!idMatchSet.has(issue.id) && matchedIds.has(issue.id)) {
108
+ nonIdMatches.push(issue)
109
+ }
110
+ }
111
+
112
+ // Sort fuzzy matches by relevance
113
+ nonIdMatches.sort((a, b) => {
114
+ const aScore =
115
+ searchResults.find(r => r.item.id === a.id)?.score ?? 1
116
+ const bScore =
117
+ searchResults.find(r => r.item.id === b.id)?.score ?? 1
118
+ return aScore - bScore // Lower score = better match
119
+ })
120
+
121
+ // ID matches first, then fuzzy matches
122
+ result = [...idMatches, ...nonIdMatches]
123
+ }
124
+
125
+ return result
121
126
  }
122
127
 
123
128
  /**
@@ -127,88 +132,88 @@ export function filterAndSearchIssues(
127
132
  * @param sortBy - Sort option: "roadmap", "priority", "scope", "created", "updated", or "id"
128
133
  */
129
134
  function sortIssues(issues: Issue[], sortBy: string): void {
130
- const sortOption = sortBy.toLowerCase()
131
-
132
- if (sortOption === 'roadmap') {
133
- issues.sort((a, b) => {
134
- const orderA = a.frontmatter.order
135
- const orderB = b.frontmatter.order
136
- if (orderA && orderB)
137
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0
138
- if (orderA && !orderB) return -1
139
- if (!orderA && orderB) return 1
140
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
141
- })
142
- } else if (sortOption === 'priority') {
143
- // Sort by priority (high → medium → low), then by ID (newest first)
144
- const priorityOrder: Record<string, number> = {
145
- high: 0,
146
- medium: 1,
147
- low: 2,
148
- }
149
- issues.sort((a, b) => {
150
- const priorityA = priorityOrder[a.frontmatter.priority] ?? 999
151
- const priorityB = priorityOrder[b.frontmatter.priority] ?? 999
152
- if (priorityA !== priorityB) return priorityA - priorityB
153
- return b.id.localeCompare(a.id) // newest first within priority
154
- })
155
- } else if (sortOption === 'scope') {
156
- // Sort by scope (small → medium → large), then by ID (newest first)
157
- // Issues without scope go to the end
158
- const scopeOrder: Record<string, number> = {
159
- small: 0,
160
- medium: 1,
161
- large: 2,
162
- }
163
- issues.sort((a, b) => {
164
- const scopeA = a.frontmatter.scope
165
- ? (scopeOrder[a.frontmatter.scope] ?? 99)
166
- : 99
167
- const scopeB = b.frontmatter.scope
168
- ? (scopeOrder[b.frontmatter.scope] ?? 99)
169
- : 99
170
- if (scopeA !== scopeB) return scopeA - scopeB
171
- return b.id.localeCompare(a.id) // newest first within scope
172
- })
173
- } else if (sortOption === 'created') {
174
- // Sort by creation date (newest first)
175
- issues.sort((a, b) => {
176
- const dateA = a.frontmatter.created || ''
177
- const dateB = b.frontmatter.created || ''
178
- if (dateA !== dateB) return dateB.localeCompare(dateA) // newest first
179
- return b.id.localeCompare(a.id) // fallback to ID
180
- })
181
- } else if (sortOption === 'created-asc') {
182
- // Sort by creation date (oldest first)
183
- issues.sort((a, b) => {
184
- const dateA = a.frontmatter.created || ''
185
- const dateB = b.frontmatter.created || ''
186
- if (dateA !== dateB) return dateA.localeCompare(dateB) // oldest first
187
- return a.id.localeCompare(b.id) // fallback to ID
188
- })
189
- } else if (sortOption === 'updated') {
190
- // Sort by last updated date (most recent first), fallback to created if no updated
191
- issues.sort((a, b) => {
192
- const dateA = a.frontmatter.updated || a.frontmatter.created || ''
193
- const dateB = b.frontmatter.updated || b.frontmatter.created || ''
194
- if (dateA !== dateB) return dateB.localeCompare(dateA) // newest first
195
- return b.id.localeCompare(a.id) // fallback to ID
196
- })
197
- } else if (sortOption === 'id') {
198
- // Sort by issue ID (newest first)
199
- issues.sort((a, b) => b.id.localeCompare(a.id))
200
- } else {
201
- // Invalid sort option - default to roadmap order
202
- issues.sort((a, b) => {
203
- const orderA = a.frontmatter.order
204
- const orderB = b.frontmatter.order
205
- if (orderA && orderB)
206
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0
207
- if (orderA && !orderB) return -1
208
- if (!orderA && orderB) return 1
209
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
210
- })
211
- }
135
+ const sortOption = sortBy.toLowerCase()
136
+
137
+ if (sortOption === 'roadmap') {
138
+ issues.sort((a, b) => {
139
+ const orderA = a.frontmatter.order
140
+ const orderB = b.frontmatter.order
141
+ if (orderA && orderB)
142
+ return orderA < orderB ? -1 : orderA > orderB ? 1 : 0
143
+ if (orderA && !orderB) return -1
144
+ if (!orderA && orderB) return 1
145
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
146
+ })
147
+ } else if (sortOption === 'priority') {
148
+ // Sort by priority (high → medium → low), then by ID (newest first)
149
+ const priorityOrder: Record<string, number> = {
150
+ high: 0,
151
+ medium: 1,
152
+ low: 2
153
+ }
154
+ issues.sort((a, b) => {
155
+ const priorityA = priorityOrder[a.frontmatter.priority] ?? 999
156
+ const priorityB = priorityOrder[b.frontmatter.priority] ?? 999
157
+ if (priorityA !== priorityB) return priorityA - priorityB
158
+ return b.id.localeCompare(a.id) // newest first within priority
159
+ })
160
+ } else if (sortOption === 'scope') {
161
+ // Sort by scope (small → medium → large), then by ID (newest first)
162
+ // Issues without scope go to the end
163
+ const scopeOrder: Record<string, number> = {
164
+ small: 0,
165
+ medium: 1,
166
+ large: 2
167
+ }
168
+ issues.sort((a, b) => {
169
+ const scopeA = a.frontmatter.scope
170
+ ? (scopeOrder[a.frontmatter.scope] ?? 99)
171
+ : 99
172
+ const scopeB = b.frontmatter.scope
173
+ ? (scopeOrder[b.frontmatter.scope] ?? 99)
174
+ : 99
175
+ if (scopeA !== scopeB) return scopeA - scopeB
176
+ return b.id.localeCompare(a.id) // newest first within scope
177
+ })
178
+ } else if (sortOption === 'created') {
179
+ // Sort by creation date (newest first)
180
+ issues.sort((a, b) => {
181
+ const dateA = a.frontmatter.created || ''
182
+ const dateB = b.frontmatter.created || ''
183
+ if (dateA !== dateB) return dateB.localeCompare(dateA) // newest first
184
+ return b.id.localeCompare(a.id) // fallback to ID
185
+ })
186
+ } else if (sortOption === 'created-asc') {
187
+ // Sort by creation date (oldest first)
188
+ issues.sort((a, b) => {
189
+ const dateA = a.frontmatter.created || ''
190
+ const dateB = b.frontmatter.created || ''
191
+ if (dateA !== dateB) return dateA.localeCompare(dateB) // oldest first
192
+ return a.id.localeCompare(b.id) // fallback to ID
193
+ })
194
+ } else if (sortOption === 'updated') {
195
+ // Sort by last updated date (most recent first), fallback to created if no updated
196
+ issues.sort((a, b) => {
197
+ const dateA = a.frontmatter.updated || a.frontmatter.created || ''
198
+ const dateB = b.frontmatter.updated || b.frontmatter.created || ''
199
+ if (dateA !== dateB) return dateB.localeCompare(dateA) // newest first
200
+ return b.id.localeCompare(a.id) // fallback to ID
201
+ })
202
+ } else if (sortOption === 'id') {
203
+ // Sort by issue ID (newest first)
204
+ issues.sort((a, b) => b.id.localeCompare(a.id))
205
+ } else {
206
+ // Invalid sort option - default to roadmap order
207
+ issues.sort((a, b) => {
208
+ const orderA = a.frontmatter.order
209
+ const orderB = b.frontmatter.order
210
+ if (orderA && orderB)
211
+ return orderA < orderB ? -1 : orderA > orderB ? 1 : 0
212
+ if (orderA && !orderB) return -1
213
+ if (!orderA && orderB) return 1
214
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
215
+ })
216
+ }
212
217
  }
213
218
 
214
219
  /**
@@ -259,129 +264,131 @@ function sortIssues(issues: Issue[], sortBy: string): void {
259
264
  * // Returns open issues matching "dashboard" via fuzzy search, sorted by relevance
260
265
  */
261
266
  export function filterByQuery(issues: Issue[], query: string): Issue[] {
262
- const parsed = parseQuery(query)
263
-
264
- // First, filter by qualifiers
265
- let result = issues.filter((issue) => {
266
- // is: qualifier (maps to status)
267
- if (parsed.qualifiers.is) {
268
- const statusValue = parsed.qualifiers.is.toLowerCase()
269
- // Only filter if value is valid (open or closed)
270
- if (statusValue === 'open' || statusValue === 'closed') {
271
- if (issue.frontmatter.status !== statusValue) {
272
- return false
273
- }
274
- }
275
- // Invalid values are ignored (issue passes filter)
276
- }
277
-
278
- // priority: qualifier
279
- if (parsed.qualifiers.priority) {
280
- const priorityValue = parsed.qualifiers.priority.toLowerCase()
281
- // Only filter if value is valid (high, medium, or low)
282
- if (
283
- priorityValue === 'high' ||
284
- priorityValue === 'medium' ||
285
- priorityValue === 'low'
286
- ) {
287
- if (issue.frontmatter.priority !== priorityValue) {
288
- return false
289
- }
290
- }
291
- // Invalid values are ignored (issue passes filter)
292
- }
293
-
294
- // scope: qualifier
295
- if (parsed.qualifiers.scope) {
296
- const scopeValue = parsed.qualifiers.scope.toLowerCase()
297
- // Only filter if value is valid (small, medium, or large)
298
- if (
299
- scopeValue === 'small' ||
300
- scopeValue === 'medium' ||
301
- scopeValue === 'large'
302
- ) {
303
- if (issue.frontmatter.scope !== scopeValue) {
304
- return false
305
- }
306
- }
307
- // Invalid values are ignored (issue passes filter)
308
- }
309
-
310
- // type: qualifier
311
- if (parsed.qualifiers.type) {
312
- const typeValue = parsed.qualifiers.type.toLowerCase()
313
- // Only filter if value is valid (bug or improvement)
314
- if (typeValue === 'bug' || typeValue === 'improvement') {
315
- if (issue.frontmatter.type !== typeValue) {
316
- return false
317
- }
318
- }
319
- // Invalid values are ignored (issue passes filter)
320
- }
321
-
322
- // label: qualifier
323
- if (parsed.qualifiers.label) {
324
- const labelQuery = parsed.qualifiers.label.toLowerCase()
325
- const issueLabels = (issue.frontmatter.labels || '').toLowerCase()
326
- // Check if the label query appears in the issue's labels (partial match)
327
- if (!issueLabels.includes(labelQuery)) {
328
- return false
329
- }
330
- }
331
-
332
- return true
333
- })
334
-
335
- // Apply sorting if no search text (search text uses relevance sorting)
336
- if (!parsed.searchText.trim()) {
337
- const sortBy = parsed.qualifiers.sort?.toLowerCase() || 'roadmap'
338
- sortIssues(result, sortBy)
339
- }
340
-
341
- // If there's search text, apply fuzzy search
342
- if (parsed.searchText.trim()) {
343
- const searchQuery = parsed.searchText.trim()
344
-
345
- // Check for ID matches first (exact prefix match)
346
- // Supports: "1" -> "0001", "01" -> "0001", "0001" -> "0001"
347
- const idMatches: Issue[] = []
348
- const nonIdMatches: Issue[] = []
349
-
350
- const normalizedQuery = searchQuery.replace(/^0+/, '') // Remove leading zeros
351
-
352
- for (const issue of result) {
353
- const normalizedId = issue.id.replace(/^0+/, '')
354
- if (
355
- normalizedId.startsWith(normalizedQuery) ||
356
- issue.id.startsWith(searchQuery)
357
- ) {
358
- idMatches.push(issue)
359
- }
360
- }
361
-
362
- // Now do fuzzy search on the filtered results
363
- const fuse = createSearchIndex(result)
364
- const searchResults = fuse.search(searchQuery)
365
- const matchedIds = new Set(searchResults.map((r) => r.item.id))
366
-
367
- // Get fuzzy matches that aren't already ID matches
368
- const idMatchSet = new Set(idMatches.map((i) => i.id))
369
- for (const issue of result) {
370
- if (!idMatchSet.has(issue.id) && matchedIds.has(issue.id)) {
371
- nonIdMatches.push(issue)
372
- }
373
- }
374
-
375
- // Sort fuzzy matches by relevance
376
- nonIdMatches.sort((a, b) => {
377
- const aScore = searchResults.find((r) => r.item.id === a.id)?.score ?? 1
378
- const bScore = searchResults.find((r) => r.item.id === b.id)?.score ?? 1
379
- return aScore - bScore // Lower score = better match
380
- })
381
-
382
- // ID matches first, then fuzzy matches
383
- result = [...idMatches, ...nonIdMatches]
384
- }
385
-
386
- return result
267
+ const parsed = parseQuery(query)
268
+
269
+ // First, filter by qualifiers
270
+ let result = issues.filter(issue => {
271
+ // is: qualifier (maps to status)
272
+ if (parsed.qualifiers.is) {
273
+ const statusValue = parsed.qualifiers.is.toLowerCase()
274
+ // Only filter if value is valid (open or closed)
275
+ if (statusValue === 'open' || statusValue === 'closed') {
276
+ if (issue.frontmatter.status !== statusValue) {
277
+ return false
278
+ }
279
+ }
280
+ // Invalid values are ignored (issue passes filter)
281
+ }
282
+
283
+ // priority: qualifier
284
+ if (parsed.qualifiers.priority) {
285
+ const priorityValue = parsed.qualifiers.priority.toLowerCase()
286
+ // Only filter if value is valid (high, medium, or low)
287
+ if (
288
+ priorityValue === 'high' ||
289
+ priorityValue === 'medium' ||
290
+ priorityValue === 'low'
291
+ ) {
292
+ if (issue.frontmatter.priority !== priorityValue) {
293
+ return false
294
+ }
295
+ }
296
+ // Invalid values are ignored (issue passes filter)
297
+ }
298
+
299
+ // scope: qualifier
300
+ if (parsed.qualifiers.scope) {
301
+ const scopeValue = parsed.qualifiers.scope.toLowerCase()
302
+ // Only filter if value is valid (small, medium, or large)
303
+ if (
304
+ scopeValue === 'small' ||
305
+ scopeValue === 'medium' ||
306
+ scopeValue === 'large'
307
+ ) {
308
+ if (issue.frontmatter.scope !== scopeValue) {
309
+ return false
310
+ }
311
+ }
312
+ // Invalid values are ignored (issue passes filter)
313
+ }
314
+
315
+ // type: qualifier
316
+ if (parsed.qualifiers.type) {
317
+ const typeValue = parsed.qualifiers.type.toLowerCase()
318
+ // Only filter if value is valid (bug or improvement)
319
+ if (typeValue === 'bug' || typeValue === 'improvement') {
320
+ if (issue.frontmatter.type !== typeValue) {
321
+ return false
322
+ }
323
+ }
324
+ // Invalid values are ignored (issue passes filter)
325
+ }
326
+
327
+ // label: qualifier
328
+ if (parsed.qualifiers.label) {
329
+ const labelQuery = parsed.qualifiers.label.toLowerCase()
330
+ const issueLabels = (issue.frontmatter.labels || '').toLowerCase()
331
+ // Check if the label query appears in the issue's labels (partial match)
332
+ if (!issueLabels.includes(labelQuery)) {
333
+ return false
334
+ }
335
+ }
336
+
337
+ return true
338
+ })
339
+
340
+ // Apply sorting if no search text (search text uses relevance sorting)
341
+ if (!parsed.searchText.trim()) {
342
+ const sortBy = parsed.qualifiers.sort?.toLowerCase() || 'roadmap'
343
+ sortIssues(result, sortBy)
344
+ }
345
+
346
+ // If there's search text, apply fuzzy search
347
+ if (parsed.searchText.trim()) {
348
+ const searchQuery = parsed.searchText.trim()
349
+
350
+ // Check for ID matches first (exact prefix match)
351
+ // Supports: "1" -> "0001", "01" -> "0001", "0001" -> "0001"
352
+ const idMatches: Issue[] = []
353
+ const nonIdMatches: Issue[] = []
354
+
355
+ const normalizedQuery = searchQuery.replace(/^0+/, '') // Remove leading zeros
356
+
357
+ for (const issue of result) {
358
+ const normalizedId = issue.id.replace(/^0+/, '')
359
+ if (
360
+ normalizedId.startsWith(normalizedQuery) ||
361
+ issue.id.startsWith(searchQuery)
362
+ ) {
363
+ idMatches.push(issue)
364
+ }
365
+ }
366
+
367
+ // Now do fuzzy search on the filtered results
368
+ const fuse = createSearchIndex(result)
369
+ const searchResults = fuse.search(searchQuery)
370
+ const matchedIds = new Set(searchResults.map(r => r.item.id))
371
+
372
+ // Get fuzzy matches that aren't already ID matches
373
+ const idMatchSet = new Set(idMatches.map(i => i.id))
374
+ for (const issue of result) {
375
+ if (!idMatchSet.has(issue.id) && matchedIds.has(issue.id)) {
376
+ nonIdMatches.push(issue)
377
+ }
378
+ }
379
+
380
+ // Sort fuzzy matches by relevance
381
+ nonIdMatches.sort((a, b) => {
382
+ const aScore =
383
+ searchResults.find(r => r.item.id === a.id)?.score ?? 1
384
+ const bScore =
385
+ searchResults.find(r => r.item.id === b.id)?.score ?? 1
386
+ return aScore - bScore // Lower score = better match
387
+ })
388
+
389
+ // ID matches first, then fuzzy matches
390
+ result = [...idMatches, ...nonIdMatches]
391
+ }
392
+
393
+ return result
387
394
  }