@miketromba/issy-core 0.1.0
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/README.md +31 -0
- package/package.json +33 -0
- package/src/lib/autocomplete.ts +234 -0
- package/src/lib/formatDate.ts +54 -0
- package/src/lib/index.ts +57 -0
- package/src/lib/issues.ts +344 -0
- package/src/lib/query-parser.ts +131 -0
- package/src/lib/search.ts +349 -0
- package/src/lib/types.ts +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @miketromba/issy-core
|
|
2
|
+
|
|
3
|
+
Shared issue storage/search library used by the issy CLI and UI.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @miketromba/issy-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { setIssuesDir, createIssue, getAllIssues } from "@miketromba/issy-core";
|
|
15
|
+
|
|
16
|
+
setIssuesDir("/path/to/repo/.issues");
|
|
17
|
+
await createIssue({
|
|
18
|
+
title: "Add dark mode",
|
|
19
|
+
description: "Theme toggle",
|
|
20
|
+
priority: "medium",
|
|
21
|
+
type: "improvement",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const issues = await getAllIssues();
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## API
|
|
28
|
+
|
|
29
|
+
- CRUD: `createIssue`, `updateIssue`, `getIssue`, `getAllIssues`, `closeIssue`
|
|
30
|
+
- Search: `filterByQuery`, `filterAndSearchIssues`
|
|
31
|
+
- Helpers: `parseQuery`, `getQuerySuggestions`
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@miketromba/issy-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Issue storage, search, and parsing for issy",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/miketromba/issy.git",
|
|
11
|
+
"directory": "packages/core"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/miketromba/issy/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/miketromba/issy#readme",
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "bun test"
|
|
22
|
+
},
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./src/lib/index.ts"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src/lib"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"date-fns": "^4.1.0",
|
|
31
|
+
"fuse.js": "^7.1.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autocomplete suggestions for query input
|
|
3
|
+
*
|
|
4
|
+
* Provides context-aware suggestions based on the current query string
|
|
5
|
+
* and cursor position.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A suggestion for autocomplete
|
|
10
|
+
*/
|
|
11
|
+
export interface Suggestion {
|
|
12
|
+
/** The text to insert when selected */
|
|
13
|
+
text: string
|
|
14
|
+
/** The text to display in the dropdown */
|
|
15
|
+
displayText: string
|
|
16
|
+
/** Optional helper text describing the suggestion */
|
|
17
|
+
description?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Supported qualifier keys
|
|
22
|
+
*/
|
|
23
|
+
const QUALIFIER_KEYS = ['is', 'priority', 'type', 'label', 'sort'] as const
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Valid values for each qualifier
|
|
27
|
+
*/
|
|
28
|
+
const QUALIFIER_VALUES: Record<string, readonly string[]> = {
|
|
29
|
+
is: ['open', 'closed'] as const,
|
|
30
|
+
priority: ['high', 'medium', 'low'] as const,
|
|
31
|
+
type: ['bug', 'improvement'] as const,
|
|
32
|
+
sort: ['priority', 'created', 'updated', 'id'] as const
|
|
33
|
+
// label values are dynamic and provided via existingLabels parameter
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get autocomplete suggestions for a query string at a given cursor position
|
|
38
|
+
*
|
|
39
|
+
* @param query - The current query string
|
|
40
|
+
* @param cursorPosition - The cursor position (defaults to end of string)
|
|
41
|
+
* @param existingLabels - Optional array of existing labels to suggest for label: qualifier
|
|
42
|
+
* @returns Array of suggestions, ordered by relevance
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* getQuerySuggestions("is:", 3)
|
|
46
|
+
* // [{ text: "open", displayText: "open", description: "Open issues" }, ...]
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* getQuerySuggestions("pri", 3)
|
|
50
|
+
* // [{ text: "priority:", displayText: "priority:", description: "Filter by priority" }, ...]
|
|
51
|
+
*/
|
|
52
|
+
export function getQuerySuggestions(
|
|
53
|
+
query: string,
|
|
54
|
+
cursorPosition?: number,
|
|
55
|
+
existingLabels?: string[]
|
|
56
|
+
): Suggestion[] {
|
|
57
|
+
// Default cursor position to end of string
|
|
58
|
+
const cursor = cursorPosition ?? query.length
|
|
59
|
+
|
|
60
|
+
// Get the text up to the cursor
|
|
61
|
+
const textBeforeCursor = query.substring(0, cursor)
|
|
62
|
+
|
|
63
|
+
// Find the current token being typed
|
|
64
|
+
const { currentToken, tokenStart } = findCurrentToken(textBeforeCursor)
|
|
65
|
+
|
|
66
|
+
// If we're in the middle of a qualifier (key:value)
|
|
67
|
+
if (currentToken.includes(':')) {
|
|
68
|
+
const colonIndex = currentToken.indexOf(':')
|
|
69
|
+
const qualifierKey = currentToken.substring(0, colonIndex)
|
|
70
|
+
const partialValue = currentToken.substring(colonIndex + 1)
|
|
71
|
+
|
|
72
|
+
// Check if it's a supported qualifier
|
|
73
|
+
if (QUALIFIER_KEYS.includes(qualifierKey as any)) {
|
|
74
|
+
return getValueSuggestions(
|
|
75
|
+
qualifierKey,
|
|
76
|
+
partialValue,
|
|
77
|
+
existingLabels
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check if we're typing a qualifier key (with or without colon)
|
|
83
|
+
const partialQualifier = currentToken
|
|
84
|
+
if (partialQualifier && !partialQualifier.includes(':')) {
|
|
85
|
+
const matchingKeys = QUALIFIER_KEYS.filter(key =>
|
|
86
|
+
key.startsWith(partialQualifier.toLowerCase())
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if (matchingKeys.length > 0) {
|
|
90
|
+
return matchingKeys.map(key => ({
|
|
91
|
+
text: `${key}:`,
|
|
92
|
+
displayText: `${key}:`,
|
|
93
|
+
description: getQualifierDescription(key)
|
|
94
|
+
}))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// If we're at the start of a new token (space or start of string)
|
|
99
|
+
// and the previous token doesn't end with a colon, suggest qualifier keys
|
|
100
|
+
if (currentToken === '' || currentToken.trim() === '') {
|
|
101
|
+
const previousToken = getPreviousToken(textBeforeCursor)
|
|
102
|
+
if (!previousToken || !previousToken.endsWith(':')) {
|
|
103
|
+
return QUALIFIER_KEYS.map(key => ({
|
|
104
|
+
text: `${key}:`,
|
|
105
|
+
displayText: `${key}:`,
|
|
106
|
+
description: getQualifierDescription(key)
|
|
107
|
+
}))
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return []
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get suggestions for a qualifier value
|
|
116
|
+
*/
|
|
117
|
+
function getValueSuggestions(
|
|
118
|
+
qualifierKey: string,
|
|
119
|
+
partialValue: string,
|
|
120
|
+
existingLabels?: string[]
|
|
121
|
+
): Suggestion[] {
|
|
122
|
+
const suggestions: Suggestion[] = []
|
|
123
|
+
|
|
124
|
+
if (qualifierKey === 'label') {
|
|
125
|
+
// For labels, use existing labels if provided
|
|
126
|
+
if (existingLabels && existingLabels.length > 0) {
|
|
127
|
+
const matchingLabels = existingLabels
|
|
128
|
+
.filter(label =>
|
|
129
|
+
label.toLowerCase().includes(partialValue.toLowerCase())
|
|
130
|
+
)
|
|
131
|
+
.slice(0, 10) // Limit to 10 suggestions
|
|
132
|
+
|
|
133
|
+
return matchingLabels.map(label => ({
|
|
134
|
+
text: label,
|
|
135
|
+
displayText: label,
|
|
136
|
+
description: 'Label'
|
|
137
|
+
}))
|
|
138
|
+
}
|
|
139
|
+
return []
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// For other qualifiers, use predefined values
|
|
143
|
+
const validValues = QUALIFIER_VALUES[qualifierKey]
|
|
144
|
+
if (!validValues) {
|
|
145
|
+
return []
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const matchingValues = validValues.filter(value =>
|
|
149
|
+
value.toLowerCase().startsWith(partialValue.toLowerCase())
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return matchingValues.map(value => ({
|
|
153
|
+
text: value,
|
|
154
|
+
displayText: value,
|
|
155
|
+
description: getValueDescription(qualifierKey, value)
|
|
156
|
+
}))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Find the current token being typed at the cursor position
|
|
161
|
+
*/
|
|
162
|
+
function findCurrentToken(text: string): {
|
|
163
|
+
currentToken: string
|
|
164
|
+
tokenStart: number
|
|
165
|
+
} {
|
|
166
|
+
if (!text) {
|
|
167
|
+
return { currentToken: '', tokenStart: 0 }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Find the start of the current token (last space or start of string)
|
|
171
|
+
let tokenStart = text.length
|
|
172
|
+
for (let i = text.length - 1; i >= 0; i--) {
|
|
173
|
+
if (text[i] === ' ') {
|
|
174
|
+
tokenStart = i + 1
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
if (i === 0) {
|
|
178
|
+
tokenStart = 0
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const currentToken = text.substring(tokenStart)
|
|
183
|
+
return { currentToken, tokenStart }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get the previous token before the cursor
|
|
188
|
+
*/
|
|
189
|
+
function getPreviousToken(text: string): string | null {
|
|
190
|
+
if (!text || text.trim() === '') {
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const tokens = text.trim().split(/\s+/)
|
|
195
|
+
if (tokens.length < 2) {
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Get the second-to-last token
|
|
200
|
+
return tokens[tokens.length - 2]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get a human-readable description for a qualifier key
|
|
205
|
+
*/
|
|
206
|
+
function getQualifierDescription(key: string): string {
|
|
207
|
+
const descriptions: Record<string, string> = {
|
|
208
|
+
is: 'Filter by status',
|
|
209
|
+
priority: 'Filter by priority',
|
|
210
|
+
type: 'Filter by type',
|
|
211
|
+
label: 'Filter by label',
|
|
212
|
+
sort: 'Sort results'
|
|
213
|
+
}
|
|
214
|
+
return descriptions[key] || ''
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get a human-readable description for a qualifier value
|
|
219
|
+
*/
|
|
220
|
+
function getValueDescription(key: string, value: string): string {
|
|
221
|
+
if (key === 'is') {
|
|
222
|
+
return value === 'open' ? 'Open issues' : 'Closed issues'
|
|
223
|
+
}
|
|
224
|
+
if (key === 'priority') {
|
|
225
|
+
return `Priority: ${value}`
|
|
226
|
+
}
|
|
227
|
+
if (key === 'type') {
|
|
228
|
+
return value === 'bug' ? 'Bug report' : 'Improvement'
|
|
229
|
+
}
|
|
230
|
+
if (key === 'sort') {
|
|
231
|
+
return `Sort by ${value}`
|
|
232
|
+
}
|
|
233
|
+
return ''
|
|
234
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { formatDistanceToNow, format, parseISO, isValid } from 'date-fns'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format a date string for user-friendly display
|
|
5
|
+
* Shows relative time for recent dates, full date for older ones
|
|
6
|
+
*/
|
|
7
|
+
export function formatDisplayDate(dateStr: string | undefined): string {
|
|
8
|
+
if (!dateStr) return ''
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
// Handle both YYYY-MM-DD and ISO formats
|
|
12
|
+
const date = dateStr.includes('T') ? parseISO(dateStr) : parseISO(dateStr + 'T00:00:00')
|
|
13
|
+
|
|
14
|
+
if (!isValid(date)) return dateStr
|
|
15
|
+
|
|
16
|
+
const now = new Date()
|
|
17
|
+
const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24))
|
|
18
|
+
|
|
19
|
+
if (diffInDays < 7) {
|
|
20
|
+
// Within last week: "2 days ago", "3 hours ago"
|
|
21
|
+
// Remove "about" prefix for cleaner output
|
|
22
|
+
return formatDistanceToNow(date, { addSuffix: true }).replace(/^about /, '')
|
|
23
|
+
} else if (diffInDays < 365) {
|
|
24
|
+
// Within last year: "Jan 15"
|
|
25
|
+
return format(date, 'MMM d')
|
|
26
|
+
} else {
|
|
27
|
+
// Older: "Jan 15, 2024"
|
|
28
|
+
return format(date, 'MMM d, yyyy')
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
return dateStr
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Format a date string for tooltip (full date and time)
|
|
37
|
+
*/
|
|
38
|
+
export function formatFullDate(dateStr: string | undefined): string {
|
|
39
|
+
if (!dateStr) return ''
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const date = dateStr.includes('T') ? parseISO(dateStr) : parseISO(dateStr + 'T00:00:00')
|
|
43
|
+
|
|
44
|
+
if (!isValid(date)) return dateStr
|
|
45
|
+
|
|
46
|
+
// If it has time info, show it
|
|
47
|
+
if (dateStr.includes('T')) {
|
|
48
|
+
return format(date, 'MMM d, yyyy \'at\' h:mm a')
|
|
49
|
+
}
|
|
50
|
+
return format(date, 'MMM d, yyyy')
|
|
51
|
+
} catch {
|
|
52
|
+
return dateStr
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue Tracking Library
|
|
3
|
+
*
|
|
4
|
+
* Shared library for reading, writing, and searching issues.
|
|
5
|
+
* Used by both the API server and CLI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Types
|
|
9
|
+
export type {
|
|
10
|
+
Issue,
|
|
11
|
+
IssueFrontmatter,
|
|
12
|
+
IssueFilters,
|
|
13
|
+
CreateIssueInput,
|
|
14
|
+
UpdateIssueInput
|
|
15
|
+
} from './types'
|
|
16
|
+
|
|
17
|
+
// Core issue operations
|
|
18
|
+
export {
|
|
19
|
+
setIssuesDir,
|
|
20
|
+
getIssuesDir,
|
|
21
|
+
ensureIssuesDir,
|
|
22
|
+
autoDetectIssuesDir,
|
|
23
|
+
parseFrontmatter,
|
|
24
|
+
generateFrontmatter,
|
|
25
|
+
getIssueIdFromFilename,
|
|
26
|
+
createSlug,
|
|
27
|
+
formatDate,
|
|
28
|
+
getIssueFiles,
|
|
29
|
+
getNextIssueNumber,
|
|
30
|
+
getIssue,
|
|
31
|
+
getAllIssues,
|
|
32
|
+
createIssue,
|
|
33
|
+
updateIssue,
|
|
34
|
+
closeIssue,
|
|
35
|
+
reopenIssue,
|
|
36
|
+
deleteIssue
|
|
37
|
+
} from './issues'
|
|
38
|
+
|
|
39
|
+
// Search functionality
|
|
40
|
+
export {
|
|
41
|
+
createSearchIndex,
|
|
42
|
+
searchIssues,
|
|
43
|
+
filterIssues,
|
|
44
|
+
filterAndSearchIssues,
|
|
45
|
+
filterByQuery
|
|
46
|
+
} from './search'
|
|
47
|
+
|
|
48
|
+
// Query parser
|
|
49
|
+
export type { ParsedQuery } from './query-parser'
|
|
50
|
+
export { parseQuery } from './query-parser'
|
|
51
|
+
|
|
52
|
+
// Autocomplete
|
|
53
|
+
export type { Suggestion } from './autocomplete'
|
|
54
|
+
export { getQuerySuggestions } from './autocomplete'
|
|
55
|
+
|
|
56
|
+
// Date formatting helpers (UI)
|
|
57
|
+
export { formatDisplayDate, formatFullDate } from './formatDate'
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core issue operations - read, write, create, update, close
|
|
3
|
+
* This is the shared library used by both the API and CLI
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import type {
|
|
9
|
+
Issue,
|
|
10
|
+
IssueFrontmatter,
|
|
11
|
+
CreateIssueInput,
|
|
12
|
+
UpdateIssueInput
|
|
13
|
+
} from './types'
|
|
14
|
+
|
|
15
|
+
// Default issues directory - can be overridden
|
|
16
|
+
let issuesDir: string | null = null
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Initialize the issues directory path
|
|
20
|
+
*/
|
|
21
|
+
export function setIssuesDir(dir: string) {
|
|
22
|
+
issuesDir = dir
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get the issues directory path
|
|
27
|
+
*/
|
|
28
|
+
export function getIssuesDir(): string {
|
|
29
|
+
if (!issuesDir) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
'Issues directory not initialized. Call setIssuesDir() first.'
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
return issuesDir
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ensure issues directory exists
|
|
39
|
+
*/
|
|
40
|
+
export async function ensureIssuesDir(): Promise<void> {
|
|
41
|
+
await mkdir(getIssuesDir(), { recursive: true })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Auto-detect issues directory from common locations
|
|
46
|
+
*/
|
|
47
|
+
export function autoDetectIssuesDir(fromPath: string): string {
|
|
48
|
+
// Try to find .issues directory by walking up from the given path
|
|
49
|
+
const { resolve, dirname } = require('node:path')
|
|
50
|
+
const { existsSync } = require('node:fs')
|
|
51
|
+
|
|
52
|
+
let current = resolve(fromPath)
|
|
53
|
+
for (let i = 0; i < 10; i++) {
|
|
54
|
+
const candidate = join(current, '.issues')
|
|
55
|
+
if (existsSync(candidate)) {
|
|
56
|
+
return candidate
|
|
57
|
+
}
|
|
58
|
+
const parent = dirname(current)
|
|
59
|
+
if (parent === current) break
|
|
60
|
+
current = parent
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw new Error('Could not find .issues directory')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse YAML front matter from issue content
|
|
68
|
+
*/
|
|
69
|
+
export function parseFrontmatter(content: string): {
|
|
70
|
+
frontmatter: Partial<IssueFrontmatter>
|
|
71
|
+
body: string
|
|
72
|
+
} {
|
|
73
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
|
74
|
+
if (!match) {
|
|
75
|
+
return { frontmatter: {}, body: content }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const [, frontmatterStr, body] = match
|
|
79
|
+
const frontmatter: Partial<IssueFrontmatter> = {}
|
|
80
|
+
|
|
81
|
+
for (const line of frontmatterStr.split('\n')) {
|
|
82
|
+
const colonIdx = line.indexOf(':')
|
|
83
|
+
if (colonIdx > 0) {
|
|
84
|
+
const key = line.slice(0, colonIdx).trim()
|
|
85
|
+
const value = line.slice(colonIdx + 1).trim()
|
|
86
|
+
;(frontmatter as Record<string, string>)[key] = value
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { frontmatter, body }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Generate YAML front matter string from issue data
|
|
95
|
+
*/
|
|
96
|
+
export function generateFrontmatter(data: IssueFrontmatter): string {
|
|
97
|
+
const lines = ['---']
|
|
98
|
+
lines.push(`title: ${data.title}`)
|
|
99
|
+
lines.push(`description: ${data.description}`)
|
|
100
|
+
lines.push(`priority: ${data.priority}`)
|
|
101
|
+
lines.push(`type: ${data.type}`)
|
|
102
|
+
if (data.labels) {
|
|
103
|
+
lines.push(`labels: ${data.labels}`)
|
|
104
|
+
}
|
|
105
|
+
lines.push(`status: ${data.status}`)
|
|
106
|
+
lines.push(`created: ${data.created}`)
|
|
107
|
+
if (data.updated) {
|
|
108
|
+
lines.push(`updated: ${data.updated}`)
|
|
109
|
+
}
|
|
110
|
+
lines.push('---')
|
|
111
|
+
return lines.join('\n')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get issue ID from filename (e.g., "0001-fix-bug.md" -> "0001")
|
|
116
|
+
*/
|
|
117
|
+
export function getIssueIdFromFilename(filename: string): string {
|
|
118
|
+
const match = filename.match(/^(\d+)-/)
|
|
119
|
+
return match ? match[1] : filename.replace('.md', '')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create URL-friendly slug from title
|
|
124
|
+
*/
|
|
125
|
+
export function createSlug(title: string): string {
|
|
126
|
+
return title
|
|
127
|
+
.toLowerCase()
|
|
128
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
129
|
+
.replace(/\s+/g, '-')
|
|
130
|
+
.replace(/-+/g, '-')
|
|
131
|
+
.slice(0, 50)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Format date as ISO 8601 timestamp (YYYY-MM-DDTHH:mm:ss)
|
|
136
|
+
* This provides second-level precision for better sorting
|
|
137
|
+
*/
|
|
138
|
+
export function formatDate(date: Date = new Date()): string {
|
|
139
|
+
return date.toISOString().slice(0, 19)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get all issue filenames from the issues directory
|
|
144
|
+
*/
|
|
145
|
+
export async function getIssueFiles(): Promise<string[]> {
|
|
146
|
+
try {
|
|
147
|
+
const files = await readdir(getIssuesDir())
|
|
148
|
+
return files.filter(f => f.endsWith('.md') && /^\d{4}-/.test(f))
|
|
149
|
+
} catch {
|
|
150
|
+
return []
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get the next available issue number
|
|
156
|
+
*/
|
|
157
|
+
export async function getNextIssueNumber(): Promise<string> {
|
|
158
|
+
const files = await getIssueFiles()
|
|
159
|
+
if (files.length === 0) return '0001'
|
|
160
|
+
|
|
161
|
+
const numbers = files
|
|
162
|
+
.map(f => parseInt(getIssueIdFromFilename(f), 10))
|
|
163
|
+
.filter(n => !isNaN(n))
|
|
164
|
+
|
|
165
|
+
const max = Math.max(...numbers, 0)
|
|
166
|
+
return String(max + 1).padStart(4, '0')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Load a single issue by ID
|
|
171
|
+
*/
|
|
172
|
+
export async function getIssue(id: string): Promise<Issue | null> {
|
|
173
|
+
const files = await getIssueFiles()
|
|
174
|
+
const paddedId = id.padStart(4, '0')
|
|
175
|
+
|
|
176
|
+
const file = files.find(
|
|
177
|
+
f => f.startsWith(paddedId) || getIssueIdFromFilename(f) === paddedId
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if (!file) return null
|
|
181
|
+
|
|
182
|
+
const filepath = join(getIssuesDir(), file)
|
|
183
|
+
const content = await readFile(filepath, 'utf-8')
|
|
184
|
+
const { frontmatter, body } = parseFrontmatter(content)
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
id: getIssueIdFromFilename(file),
|
|
188
|
+
filename: file,
|
|
189
|
+
frontmatter: frontmatter as IssueFrontmatter,
|
|
190
|
+
content: body
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Load all issues
|
|
196
|
+
*/
|
|
197
|
+
export async function getAllIssues(): Promise<Issue[]> {
|
|
198
|
+
const files = await getIssueFiles()
|
|
199
|
+
const issues: Issue[] = []
|
|
200
|
+
|
|
201
|
+
for (const file of files) {
|
|
202
|
+
const filepath = join(getIssuesDir(), file)
|
|
203
|
+
const content = await readFile(filepath, 'utf-8')
|
|
204
|
+
const { frontmatter, body } = parseFrontmatter(content)
|
|
205
|
+
|
|
206
|
+
issues.push({
|
|
207
|
+
id: getIssueIdFromFilename(file),
|
|
208
|
+
filename: file,
|
|
209
|
+
frontmatter: frontmatter as IssueFrontmatter,
|
|
210
|
+
content: body
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Sort by priority (high → medium → low), then by ID (newest first) within each priority
|
|
215
|
+
const priorityOrder: Record<string, number> = { high: 0, medium: 1, low: 2 }
|
|
216
|
+
return issues.sort((a, b) => {
|
|
217
|
+
const priorityA = priorityOrder[a.frontmatter.priority] ?? 999
|
|
218
|
+
const priorityB = priorityOrder[b.frontmatter.priority] ?? 999
|
|
219
|
+
|
|
220
|
+
if (priorityA !== priorityB) {
|
|
221
|
+
return priorityA - priorityB
|
|
222
|
+
}
|
|
223
|
+
// Within same priority, sort by ID descending (newest first)
|
|
224
|
+
return b.id.localeCompare(a.id)
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Create a new issue
|
|
230
|
+
*/
|
|
231
|
+
export async function createIssue(input: CreateIssueInput): Promise<Issue> {
|
|
232
|
+
await ensureIssuesDir()
|
|
233
|
+
if (!input.title) {
|
|
234
|
+
throw new Error('Title is required')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const priority = input.priority || 'medium'
|
|
238
|
+
const type = input.type || 'improvement'
|
|
239
|
+
|
|
240
|
+
if (!['high', 'medium', 'low'].includes(priority)) {
|
|
241
|
+
throw new Error('Priority must be: high, medium, or low')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!['bug', 'improvement'].includes(type)) {
|
|
245
|
+
throw new Error('Type must be: bug or improvement')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const issueNumber = await getNextIssueNumber()
|
|
249
|
+
const slug = createSlug(input.title)
|
|
250
|
+
const filename = `${issueNumber}-${slug}.md`
|
|
251
|
+
|
|
252
|
+
const frontmatter: IssueFrontmatter = {
|
|
253
|
+
title: input.title,
|
|
254
|
+
description: input.description || input.title,
|
|
255
|
+
priority,
|
|
256
|
+
type,
|
|
257
|
+
labels: input.labels || undefined,
|
|
258
|
+
status: 'open',
|
|
259
|
+
created: formatDate()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const content = `${generateFrontmatter(frontmatter)}
|
|
263
|
+
|
|
264
|
+
## Details
|
|
265
|
+
|
|
266
|
+
<!-- Add detailed description here -->
|
|
267
|
+
|
|
268
|
+
`
|
|
269
|
+
|
|
270
|
+
await writeFile(join(getIssuesDir(), filename), content)
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
id: issueNumber,
|
|
274
|
+
filename,
|
|
275
|
+
frontmatter,
|
|
276
|
+
content: '\n## Details\n\n<!-- Add detailed description here -->\n\n'
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Update an existing issue
|
|
282
|
+
*/
|
|
283
|
+
export async function updateIssue(
|
|
284
|
+
id: string,
|
|
285
|
+
input: UpdateIssueInput
|
|
286
|
+
): Promise<Issue> {
|
|
287
|
+
const issue = await getIssue(id)
|
|
288
|
+
|
|
289
|
+
if (!issue) {
|
|
290
|
+
throw new Error(`Issue not found: ${id}`)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Update fields
|
|
294
|
+
const updatedFrontmatter: IssueFrontmatter = {
|
|
295
|
+
...issue.frontmatter,
|
|
296
|
+
...(input.title && { title: input.title }),
|
|
297
|
+
...(input.description && { description: input.description }),
|
|
298
|
+
...(input.priority && { priority: input.priority }),
|
|
299
|
+
...(input.type && { type: input.type }),
|
|
300
|
+
...(input.labels !== undefined && {
|
|
301
|
+
labels: input.labels || undefined
|
|
302
|
+
}),
|
|
303
|
+
...(input.status && { status: input.status }),
|
|
304
|
+
updated: formatDate()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const content = `${generateFrontmatter(updatedFrontmatter)}
|
|
308
|
+
${issue.content}`
|
|
309
|
+
|
|
310
|
+
await writeFile(join(getIssuesDir(), issue.filename), content)
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
...issue,
|
|
314
|
+
frontmatter: updatedFrontmatter
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Close an issue
|
|
320
|
+
*/
|
|
321
|
+
export async function closeIssue(id: string): Promise<Issue> {
|
|
322
|
+
return updateIssue(id, { status: 'closed' })
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Reopen an issue
|
|
327
|
+
*/
|
|
328
|
+
export async function reopenIssue(id: string): Promise<Issue> {
|
|
329
|
+
return updateIssue(id, { status: 'open' })
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Delete an issue permanently
|
|
334
|
+
*/
|
|
335
|
+
export async function deleteIssue(id: string): Promise<void> {
|
|
336
|
+
const issue = await getIssue(id)
|
|
337
|
+
|
|
338
|
+
if (!issue) {
|
|
339
|
+
throw new Error(`Issue not found: ${id}`)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const { unlink } = await import('node:fs/promises')
|
|
343
|
+
await unlink(join(getIssuesDir(), issue.filename))
|
|
344
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query language parser for issues explorer
|
|
3
|
+
*
|
|
4
|
+
* Parses GitHub-style query syntax to extract qualifiers and free text.
|
|
5
|
+
* Example: "is:open priority:high dashboard" -> { qualifiers: { is: "open", priority: "high" }, searchText: "dashboard" }
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parsed query result containing extracted qualifiers and search text
|
|
10
|
+
*/
|
|
11
|
+
export interface ParsedQuery {
|
|
12
|
+
qualifiers: Record<string, string>
|
|
13
|
+
searchText: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Supported qualifier keys
|
|
18
|
+
*/
|
|
19
|
+
const SUPPORTED_QUALIFIERS = new Set([
|
|
20
|
+
'is',
|
|
21
|
+
'priority',
|
|
22
|
+
'type',
|
|
23
|
+
'label',
|
|
24
|
+
'sort'
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse a query string into qualifiers and free text
|
|
29
|
+
*
|
|
30
|
+
* @param query - The query string to parse (e.g., "is:open priority:high dashboard")
|
|
31
|
+
* @returns Parsed query with qualifiers object and search text string
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* parseQuery("is:open priority:high dashboard")
|
|
35
|
+
* // { qualifiers: { is: "open", priority: "high" }, searchText: "dashboard" }
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* parseQuery("type:bug label:frontend k8s cluster")
|
|
39
|
+
* // { qualifiers: { type: "bug", label: "frontend" }, searchText: "k8s cluster" }
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* parseQuery("dashboard")
|
|
43
|
+
* // { qualifiers: {}, searchText: "dashboard" }
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* parseQuery("is:open")
|
|
47
|
+
* // { qualifiers: { is: "open" }, searchText: "" }
|
|
48
|
+
*/
|
|
49
|
+
export function parseQuery(query: string): ParsedQuery {
|
|
50
|
+
const qualifiers: Record<string, string> = {}
|
|
51
|
+
const searchTextParts: string[] = []
|
|
52
|
+
|
|
53
|
+
if (!query || !query.trim()) {
|
|
54
|
+
return { qualifiers, searchText: '' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Split by spaces, but preserve quoted strings
|
|
58
|
+
const tokens = tokenizeQuery(query)
|
|
59
|
+
|
|
60
|
+
for (const token of tokens) {
|
|
61
|
+
// Check if token matches key:value pattern
|
|
62
|
+
const colonIndex = token.indexOf(':')
|
|
63
|
+
|
|
64
|
+
if (colonIndex > 0 && colonIndex < token.length - 1) {
|
|
65
|
+
const key = token.substring(0, colonIndex)
|
|
66
|
+
const value = token.substring(colonIndex + 1)
|
|
67
|
+
|
|
68
|
+
// Only extract if it's a supported qualifier
|
|
69
|
+
// Unknown qualifiers are treated as search text
|
|
70
|
+
if (SUPPORTED_QUALIFIERS.has(key)) {
|
|
71
|
+
qualifiers[key] = value
|
|
72
|
+
} else {
|
|
73
|
+
// Unknown qualifier format - treat as search text
|
|
74
|
+
searchTextParts.push(token)
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
// No colon or invalid format - treat as search text
|
|
78
|
+
searchTextParts.push(token)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
qualifiers,
|
|
84
|
+
searchText: searchTextParts.join(' ').trim()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Tokenize a query string, handling quoted strings
|
|
90
|
+
*
|
|
91
|
+
* @param query - The query string to tokenize
|
|
92
|
+
* @returns Array of tokens
|
|
93
|
+
*/
|
|
94
|
+
function tokenizeQuery(query: string): string[] {
|
|
95
|
+
const tokens: string[] = []
|
|
96
|
+
let currentToken = ''
|
|
97
|
+
let inQuotes = false
|
|
98
|
+
let quoteChar = ''
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < query.length; i++) {
|
|
101
|
+
const char = query[i]
|
|
102
|
+
|
|
103
|
+
if ((char === '"' || char === "'") && !inQuotes) {
|
|
104
|
+
// Start of quoted string
|
|
105
|
+
inQuotes = true
|
|
106
|
+
quoteChar = char
|
|
107
|
+
// Don't include the quote in the token
|
|
108
|
+
} else if (char === quoteChar && inQuotes) {
|
|
109
|
+
// End of quoted string
|
|
110
|
+
inQuotes = false
|
|
111
|
+
quoteChar = ''
|
|
112
|
+
// Don't include the quote in the token
|
|
113
|
+
} else if (char === ' ' && !inQuotes) {
|
|
114
|
+
// Space outside quotes - end of token
|
|
115
|
+
if (currentToken) {
|
|
116
|
+
tokens.push(currentToken)
|
|
117
|
+
currentToken = ''
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
// Regular character - add to current token
|
|
121
|
+
currentToken += char
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Add final token if exists
|
|
126
|
+
if (currentToken) {
|
|
127
|
+
tokens.push(currentToken)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return tokens
|
|
131
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzzy search functionality for issues using Fuse.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import Fuse, { type IFuseOptions } from 'fuse.js'
|
|
6
|
+
import type { Issue, IssueFilters } from './types'
|
|
7
|
+
import { parseQuery, type ParsedQuery } from './query-parser'
|
|
8
|
+
|
|
9
|
+
// Fuse.js configuration for fuzzy search
|
|
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
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a Fuse.js instance for searching issues
|
|
24
|
+
*/
|
|
25
|
+
export function createSearchIndex(issues: Issue[]): Fuse<Issue> {
|
|
26
|
+
return new Fuse(issues, FUSE_OPTIONS)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Search issues with fuzzy matching
|
|
31
|
+
* Returns issues sorted by relevance
|
|
32
|
+
*/
|
|
33
|
+
export function searchIssues(fuse: Fuse<Issue>, query: string): Issue[] {
|
|
34
|
+
if (!query.trim()) {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const results = fuse.search(query)
|
|
39
|
+
return results.map(r => r.item)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Filter issues by frontmatter fields
|
|
44
|
+
*/
|
|
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 (
|
|
51
|
+
filters.priority &&
|
|
52
|
+
issue.frontmatter.priority !== filters.priority
|
|
53
|
+
) {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
if (filters.type && issue.frontmatter.type !== filters.type) {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
return true
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Filter and search issues
|
|
65
|
+
* Applies filters first, then fuzzy search if query provided
|
|
66
|
+
* ID matches (exact prefix) are ranked first
|
|
67
|
+
*/
|
|
68
|
+
export function filterAndSearchIssues(
|
|
69
|
+
issues: Issue[],
|
|
70
|
+
filters: IssueFilters
|
|
71
|
+
): 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 =
|
|
112
|
+
searchResults.find(r => r.item.id === a.id)?.score ?? 1
|
|
113
|
+
const bScore =
|
|
114
|
+
searchResults.find(r => r.item.id === b.id)?.score ?? 1
|
|
115
|
+
return aScore - bScore // Lower score = better match
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// ID matches first, then fuzzy matches
|
|
119
|
+
result = [...idMatches, ...nonIdMatches]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return result
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Sort issues by the specified sort option
|
|
127
|
+
*
|
|
128
|
+
* @param issues - Array of issues to sort (modified in place)
|
|
129
|
+
* @param sortBy - Sort option: "priority", "created", "updated", or "id"
|
|
130
|
+
*/
|
|
131
|
+
function sortIssues(issues: Issue[], sortBy: string): void {
|
|
132
|
+
const sortOption = sortBy.toLowerCase()
|
|
133
|
+
|
|
134
|
+
if (sortOption === 'priority') {
|
|
135
|
+
// Sort by priority (high → medium → low), then by ID (newest first)
|
|
136
|
+
const priorityOrder: Record<string, number> = {
|
|
137
|
+
high: 0,
|
|
138
|
+
medium: 1,
|
|
139
|
+
low: 2
|
|
140
|
+
}
|
|
141
|
+
issues.sort((a, b) => {
|
|
142
|
+
const priorityA = priorityOrder[a.frontmatter.priority] ?? 999
|
|
143
|
+
const priorityB = priorityOrder[b.frontmatter.priority] ?? 999
|
|
144
|
+
if (priorityA !== priorityB) return priorityA - priorityB
|
|
145
|
+
return b.id.localeCompare(a.id) // newest first within priority
|
|
146
|
+
})
|
|
147
|
+
} else if (sortOption === 'created') {
|
|
148
|
+
// Sort by creation date (newest first)
|
|
149
|
+
issues.sort((a, b) => {
|
|
150
|
+
const dateA = a.frontmatter.created || ''
|
|
151
|
+
const dateB = b.frontmatter.created || ''
|
|
152
|
+
if (dateA !== dateB) return dateB.localeCompare(dateA) // newest first
|
|
153
|
+
return b.id.localeCompare(a.id) // fallback to ID
|
|
154
|
+
})
|
|
155
|
+
} else if (sortOption === 'created-asc') {
|
|
156
|
+
// Sort by creation date (oldest first)
|
|
157
|
+
issues.sort((a, b) => {
|
|
158
|
+
const dateA = a.frontmatter.created || ''
|
|
159
|
+
const dateB = b.frontmatter.created || ''
|
|
160
|
+
if (dateA !== dateB) return dateA.localeCompare(dateB) // oldest first
|
|
161
|
+
return a.id.localeCompare(b.id) // fallback to ID
|
|
162
|
+
})
|
|
163
|
+
} else if (sortOption === 'updated') {
|
|
164
|
+
// Sort by last updated date (most recent first), fallback to created if no updated
|
|
165
|
+
issues.sort((a, b) => {
|
|
166
|
+
const dateA = a.frontmatter.updated || a.frontmatter.created || ''
|
|
167
|
+
const dateB = b.frontmatter.updated || b.frontmatter.created || ''
|
|
168
|
+
if (dateA !== dateB) return dateB.localeCompare(dateA) // newest first
|
|
169
|
+
return b.id.localeCompare(a.id) // fallback to ID
|
|
170
|
+
})
|
|
171
|
+
} else if (sortOption === 'id') {
|
|
172
|
+
// Sort by issue ID (newest first)
|
|
173
|
+
issues.sort((a, b) => b.id.localeCompare(a.id))
|
|
174
|
+
} else {
|
|
175
|
+
// Invalid sort option - default to priority sort
|
|
176
|
+
const priorityOrder: Record<string, number> = {
|
|
177
|
+
high: 0,
|
|
178
|
+
medium: 1,
|
|
179
|
+
low: 2
|
|
180
|
+
}
|
|
181
|
+
issues.sort((a, b) => {
|
|
182
|
+
const priorityA = priorityOrder[a.frontmatter.priority] ?? 999
|
|
183
|
+
const priorityB = priorityOrder[b.frontmatter.priority] ?? 999
|
|
184
|
+
if (priorityA !== priorityB) return priorityA - priorityB
|
|
185
|
+
return b.id.localeCompare(a.id) // newest first within priority
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Filter issues using parsed query qualifiers and fuzzy search
|
|
192
|
+
*
|
|
193
|
+
* Supports the following qualifiers:
|
|
194
|
+
* - `is:open` / `is:closed` - filters by status
|
|
195
|
+
* - `priority:high` / `priority:medium` / `priority:low` - filters by priority
|
|
196
|
+
* - `type:bug` / `type:improvement` - filters by type
|
|
197
|
+
* - `label:x` - filters by label (case-insensitive partial match)
|
|
198
|
+
* - `sort:priority` / `sort:created` / `sort:created-asc` / `sort:updated` / `sort:id` - sorts results
|
|
199
|
+
*
|
|
200
|
+
* Any remaining free text after qualifiers triggers fuzzy search across title,
|
|
201
|
+
* description, labels, and content. Results are sorted by relevance when search
|
|
202
|
+
* text is present. When no search text is provided, results are sorted by the
|
|
203
|
+
* `sort:` qualifier (defaults to priority if not specified). ID prefix matching
|
|
204
|
+
* is supported (e.g., "1" matches #0001).
|
|
205
|
+
*
|
|
206
|
+
* Invalid qualifier values are ignored (issue passes filter).
|
|
207
|
+
* Multiple qualifiers use AND logic (all must match).
|
|
208
|
+
*
|
|
209
|
+
* @param issues - Array of issues to filter
|
|
210
|
+
* @param query - Query string containing qualifiers and/or search text (e.g., "is:open dashboard")
|
|
211
|
+
* @returns Filtered array of issues matching all qualifiers and search text
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* filterByQuery(issues, "is:open")
|
|
215
|
+
* // Returns only open issues, sorted by priority (default)
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* filterByQuery(issues, "is:open sort:created")
|
|
219
|
+
* // Returns only open issues, sorted by creation date (newest first)
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* filterByQuery(issues, "is:open priority:high type:bug")
|
|
223
|
+
* // Returns only open, high priority bugs, sorted by priority (default)
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* filterByQuery(issues, "label:frontend sort:updated")
|
|
227
|
+
* // Returns only issues with "frontend" in their labels, sorted by update date
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* filterByQuery(issues, "dashboard")
|
|
231
|
+
* // Returns issues matching "dashboard" via fuzzy search, sorted by relevance
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* filterByQuery(issues, "is:open dashboard")
|
|
235
|
+
* // Returns open issues matching "dashboard" via fuzzy search, sorted by relevance
|
|
236
|
+
*/
|
|
237
|
+
export function filterByQuery(issues: Issue[], query: string): Issue[] {
|
|
238
|
+
const parsed = parseQuery(query)
|
|
239
|
+
|
|
240
|
+
// First, filter by qualifiers
|
|
241
|
+
let result = issues.filter(issue => {
|
|
242
|
+
// is: qualifier (maps to status)
|
|
243
|
+
if (parsed.qualifiers.is) {
|
|
244
|
+
const statusValue = parsed.qualifiers.is.toLowerCase()
|
|
245
|
+
// Only filter if value is valid (open or closed)
|
|
246
|
+
if (statusValue === 'open' || statusValue === 'closed') {
|
|
247
|
+
if (issue.frontmatter.status !== statusValue) {
|
|
248
|
+
return false
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Invalid values are ignored (issue passes filter)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// priority: qualifier
|
|
255
|
+
if (parsed.qualifiers.priority) {
|
|
256
|
+
const priorityValue = parsed.qualifiers.priority.toLowerCase()
|
|
257
|
+
// Only filter if value is valid (high, medium, or low)
|
|
258
|
+
if (
|
|
259
|
+
priorityValue === 'high' ||
|
|
260
|
+
priorityValue === 'medium' ||
|
|
261
|
+
priorityValue === 'low'
|
|
262
|
+
) {
|
|
263
|
+
if (issue.frontmatter.priority !== priorityValue) {
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Invalid values are ignored (issue passes filter)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// type: qualifier
|
|
271
|
+
if (parsed.qualifiers.type) {
|
|
272
|
+
const typeValue = parsed.qualifiers.type.toLowerCase()
|
|
273
|
+
// Only filter if value is valid (bug or improvement)
|
|
274
|
+
if (typeValue === 'bug' || typeValue === 'improvement') {
|
|
275
|
+
if (issue.frontmatter.type !== typeValue) {
|
|
276
|
+
return false
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Invalid values are ignored (issue passes filter)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// label: qualifier
|
|
283
|
+
if (parsed.qualifiers.label) {
|
|
284
|
+
const labelQuery = parsed.qualifiers.label.toLowerCase()
|
|
285
|
+
const issueLabels = (issue.frontmatter.labels || '').toLowerCase()
|
|
286
|
+
// Check if the label query appears in the issue's labels (partial match)
|
|
287
|
+
if (!issueLabels.includes(labelQuery)) {
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return true
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// Apply sorting if no search text (search text uses relevance sorting)
|
|
296
|
+
if (!parsed.searchText.trim()) {
|
|
297
|
+
const sortBy = parsed.qualifiers.sort?.toLowerCase() || 'priority'
|
|
298
|
+
sortIssues(result, sortBy)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// If there's search text, apply fuzzy search
|
|
302
|
+
if (parsed.searchText.trim()) {
|
|
303
|
+
const searchQuery = parsed.searchText.trim()
|
|
304
|
+
|
|
305
|
+
// Check for ID matches first (exact prefix match)
|
|
306
|
+
// Supports: "1" -> "0001", "01" -> "0001", "0001" -> "0001"
|
|
307
|
+
const idMatches: Issue[] = []
|
|
308
|
+
const nonIdMatches: Issue[] = []
|
|
309
|
+
|
|
310
|
+
const normalizedQuery = searchQuery.replace(/^0+/, '') // Remove leading zeros
|
|
311
|
+
|
|
312
|
+
for (const issue of result) {
|
|
313
|
+
const normalizedId = issue.id.replace(/^0+/, '')
|
|
314
|
+
if (
|
|
315
|
+
normalizedId.startsWith(normalizedQuery) ||
|
|
316
|
+
issue.id.startsWith(searchQuery)
|
|
317
|
+
) {
|
|
318
|
+
idMatches.push(issue)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Now do fuzzy search on the filtered results
|
|
323
|
+
const fuse = createSearchIndex(result)
|
|
324
|
+
const searchResults = fuse.search(searchQuery)
|
|
325
|
+
const matchedIds = new Set(searchResults.map(r => r.item.id))
|
|
326
|
+
|
|
327
|
+
// Get fuzzy matches that aren't already ID matches
|
|
328
|
+
const idMatchSet = new Set(idMatches.map(i => i.id))
|
|
329
|
+
for (const issue of result) {
|
|
330
|
+
if (!idMatchSet.has(issue.id) && matchedIds.has(issue.id)) {
|
|
331
|
+
nonIdMatches.push(issue)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Sort fuzzy matches by relevance
|
|
336
|
+
nonIdMatches.sort((a, b) => {
|
|
337
|
+
const aScore =
|
|
338
|
+
searchResults.find(r => r.item.id === a.id)?.score ?? 1
|
|
339
|
+
const bScore =
|
|
340
|
+
searchResults.find(r => r.item.id === b.id)?.score ?? 1
|
|
341
|
+
return aScore - bScore // Lower score = better match
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
// ID matches first, then fuzzy matches
|
|
345
|
+
result = [...idMatches, ...nonIdMatches]
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return result
|
|
349
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the issue tracking system
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface IssueFrontmatter {
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
priority: "high" | "medium" | "low";
|
|
9
|
+
type: "bug" | "improvement";
|
|
10
|
+
labels?: string;
|
|
11
|
+
status: "open" | "closed";
|
|
12
|
+
created: string;
|
|
13
|
+
updated?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Issue {
|
|
17
|
+
id: string;
|
|
18
|
+
filename: string;
|
|
19
|
+
frontmatter: IssueFrontmatter;
|
|
20
|
+
content: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IssueFilters {
|
|
24
|
+
status?: string;
|
|
25
|
+
priority?: string;
|
|
26
|
+
type?: string;
|
|
27
|
+
search?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CreateIssueInput {
|
|
31
|
+
title: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
priority?: "high" | "medium" | "low";
|
|
34
|
+
type?: "bug" | "improvement";
|
|
35
|
+
labels?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface UpdateIssueInput {
|
|
39
|
+
title?: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
priority?: "high" | "medium" | "low";
|
|
42
|
+
type?: "bug" | "improvement";
|
|
43
|
+
labels?: string;
|
|
44
|
+
status?: "open" | "closed";
|
|
45
|
+
}
|