@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 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
+ }
@@ -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
+ }
@@ -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
+ }