@miketromba/issy-core 0.1.0 → 0.1.1
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/package.json +3 -2
- package/src/lib/autocomplete.ts +3 -3
- package/src/lib/formatDate.ts +22 -13
- package/src/lib/index.ts +39 -44
- package/src/lib/issues.ts +203 -203
- package/src/lib/query-parser.ts +69 -69
- package/src/lib/search.ts +244 -251
- package/src/lib/types.ts +27 -27
package/src/lib/issues.ts
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
* This is the shared library used by both the API and CLI
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { readdir, readFile, writeFile
|
|
6
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
7
7
|
import { join } from 'node:path'
|
|
8
8
|
import type {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
CreateIssueInput,
|
|
10
|
+
Issue,
|
|
11
|
+
IssueFrontmatter,
|
|
12
|
+
UpdateIssueInput,
|
|
13
13
|
} from './types'
|
|
14
14
|
|
|
15
15
|
// Default issues directory - can be overridden
|
|
@@ -19,116 +19,116 @@ let issuesDir: string | null = null
|
|
|
19
19
|
* Initialize the issues directory path
|
|
20
20
|
*/
|
|
21
21
|
export function setIssuesDir(dir: string) {
|
|
22
|
-
|
|
22
|
+
issuesDir = dir
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Get the issues directory path
|
|
27
27
|
*/
|
|
28
28
|
export function getIssuesDir(): string {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
if (!issuesDir) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
'Issues directory not initialized. Call setIssuesDir() first.',
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
return issuesDir
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Ensure issues directory exists
|
|
39
39
|
*/
|
|
40
40
|
export async function ensureIssuesDir(): Promise<void> {
|
|
41
|
-
|
|
41
|
+
await mkdir(getIssuesDir(), { recursive: true })
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Auto-detect issues directory from common locations
|
|
46
46
|
*/
|
|
47
47
|
export function autoDetectIssuesDir(fromPath: string): string {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
67
|
* Parse YAML front matter from issue content
|
|
68
68
|
*/
|
|
69
69
|
export function parseFrontmatter(content: string): {
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
frontmatter: Partial<IssueFrontmatter>
|
|
71
|
+
body: string
|
|
72
72
|
} {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
94
|
* Generate YAML front matter string from issue data
|
|
95
95
|
*/
|
|
96
96
|
export function generateFrontmatter(data: IssueFrontmatter): string {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
112
|
}
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
115
|
* Get issue ID from filename (e.g., "0001-fix-bug.md" -> "0001")
|
|
116
116
|
*/
|
|
117
117
|
export function getIssueIdFromFilename(filename: string): string {
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
const match = filename.match(/^(\d+)-/)
|
|
119
|
+
return match ? match[1] : filename.replace('.md', '')
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
123
|
* Create URL-friendly slug from title
|
|
124
124
|
*/
|
|
125
125
|
export function createSlug(title: string): string {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
126
|
+
return title
|
|
127
|
+
.toLowerCase()
|
|
128
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
129
|
+
.replace(/\s+/g, '-')
|
|
130
|
+
.replace(/-+/g, '-')
|
|
131
|
+
.slice(0, 50)
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
@@ -136,130 +136,130 @@ export function createSlug(title: string): string {
|
|
|
136
136
|
* This provides second-level precision for better sorting
|
|
137
137
|
*/
|
|
138
138
|
export function formatDate(date: Date = new Date()): string {
|
|
139
|
-
|
|
139
|
+
return date.toISOString().slice(0, 19)
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
143
|
* Get all issue filenames from the issues directory
|
|
144
144
|
*/
|
|
145
145
|
export async function getIssueFiles(): Promise<string[]> {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
152
|
}
|
|
153
153
|
|
|
154
154
|
/**
|
|
155
155
|
* Get the next available issue number
|
|
156
156
|
*/
|
|
157
157
|
export async function getNextIssueNumber(): Promise<string> {
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
const files = await getIssueFiles()
|
|
159
|
+
if (files.length === 0) return '0001'
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
const numbers = files
|
|
162
|
+
.map((f) => parseInt(getIssueIdFromFilename(f), 10))
|
|
163
|
+
.filter((n) => !Number.isNaN(n))
|
|
164
164
|
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
const max = Math.max(...numbers, 0)
|
|
166
|
+
return String(max + 1).padStart(4, '0')
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
170
|
* Load a single issue by ID
|
|
171
171
|
*/
|
|
172
172
|
export async function getIssue(id: string): Promise<Issue | null> {
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
const files = await getIssueFiles()
|
|
174
|
+
const paddedId = id.padStart(4, '0')
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
const file = files.find(
|
|
177
|
+
(f) => f.startsWith(paddedId) || getIssueIdFromFilename(f) === paddedId,
|
|
178
|
+
)
|
|
179
179
|
|
|
180
|
-
|
|
180
|
+
if (!file) return null
|
|
181
181
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
const filepath = join(getIssuesDir(), file)
|
|
183
|
+
const content = await readFile(filepath, 'utf-8')
|
|
184
|
+
const { frontmatter, body } = parseFrontmatter(content)
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
186
|
+
return {
|
|
187
|
+
id: getIssueIdFromFilename(file),
|
|
188
|
+
filename: file,
|
|
189
|
+
frontmatter: frontmatter as IssueFrontmatter,
|
|
190
|
+
content: body,
|
|
191
|
+
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
/**
|
|
195
195
|
* Load all issues
|
|
196
196
|
*/
|
|
197
197
|
export async function getAllIssues(): Promise<Issue[]> {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
226
|
}
|
|
227
227
|
|
|
228
228
|
/**
|
|
229
229
|
* Create a new issue
|
|
230
230
|
*/
|
|
231
231
|
export async function createIssue(input: CreateIssueInput): Promise<Issue> {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
263
|
|
|
264
264
|
## Details
|
|
265
265
|
|
|
@@ -267,78 +267,78 @@ export async function createIssue(input: CreateIssueInput): Promise<Issue> {
|
|
|
267
267
|
|
|
268
268
|
`
|
|
269
269
|
|
|
270
|
-
|
|
270
|
+
await writeFile(join(getIssuesDir(), filename), content)
|
|
271
271
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
272
|
+
return {
|
|
273
|
+
id: issueNumber,
|
|
274
|
+
filename,
|
|
275
|
+
frontmatter,
|
|
276
|
+
content: '\n## Details\n\n<!-- Add detailed description here -->\n\n',
|
|
277
|
+
}
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
/**
|
|
281
281
|
* Update an existing issue
|
|
282
282
|
*/
|
|
283
283
|
export async function updateIssue(
|
|
284
|
-
|
|
285
|
-
|
|
284
|
+
id: string,
|
|
285
|
+
input: UpdateIssueInput,
|
|
286
286
|
): Promise<Issue> {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
308
|
${issue.content}`
|
|
309
309
|
|
|
310
|
-
|
|
310
|
+
await writeFile(join(getIssuesDir(), issue.filename), content)
|
|
311
311
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
312
|
+
return {
|
|
313
|
+
...issue,
|
|
314
|
+
frontmatter: updatedFrontmatter,
|
|
315
|
+
}
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
/**
|
|
319
319
|
* Close an issue
|
|
320
320
|
*/
|
|
321
321
|
export async function closeIssue(id: string): Promise<Issue> {
|
|
322
|
-
|
|
322
|
+
return updateIssue(id, { status: 'closed' })
|
|
323
323
|
}
|
|
324
324
|
|
|
325
325
|
/**
|
|
326
326
|
* Reopen an issue
|
|
327
327
|
*/
|
|
328
328
|
export async function reopenIssue(id: string): Promise<Issue> {
|
|
329
|
-
|
|
329
|
+
return updateIssue(id, { status: 'open' })
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
/**
|
|
333
333
|
* Delete an issue permanently
|
|
334
334
|
*/
|
|
335
335
|
export async function deleteIssue(id: string): Promise<void> {
|
|
336
|
-
|
|
336
|
+
const issue = await getIssue(id)
|
|
337
337
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
338
|
+
if (!issue) {
|
|
339
|
+
throw new Error(`Issue not found: ${id}`)
|
|
340
|
+
}
|
|
341
341
|
|
|
342
|
-
|
|
343
|
-
|
|
342
|
+
const { unlink } = await import('node:fs/promises')
|
|
343
|
+
await unlink(join(getIssuesDir(), issue.filename))
|
|
344
344
|
}
|