@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/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, mkdir } from 'node:fs/promises'
6
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
7
7
  import { join } from 'node:path'
8
8
  import type {
9
- Issue,
10
- IssueFrontmatter,
11
- CreateIssueInput,
12
- UpdateIssueInput
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
- issuesDir = dir
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
- if (!issuesDir) {
30
- throw new Error(
31
- 'Issues directory not initialized. Call setIssuesDir() first.'
32
- )
33
- }
34
- return issuesDir
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
- await mkdir(getIssuesDir(), { recursive: true })
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
- // 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')
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
- frontmatter: Partial<IssueFrontmatter>
71
- body: string
70
+ frontmatter: Partial<IssueFrontmatter>
71
+ body: string
72
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 }
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
- 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')
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
- const match = filename.match(/^(\d+)-/)
119
- return match ? match[1] : filename.replace('.md', '')
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
- return title
127
- .toLowerCase()
128
- .replace(/[^a-z0-9\s-]/g, '')
129
- .replace(/\s+/g, '-')
130
- .replace(/-+/g, '-')
131
- .slice(0, 50)
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
- return date.toISOString().slice(0, 19)
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
- try {
147
- const files = await readdir(getIssuesDir())
148
- return files.filter(f => f.endsWith('.md') && /^\d{4}-/.test(f))
149
- } catch {
150
- return []
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
- const files = await getIssueFiles()
159
- if (files.length === 0) return '0001'
158
+ const files = await getIssueFiles()
159
+ if (files.length === 0) return '0001'
160
160
 
161
- const numbers = files
162
- .map(f => parseInt(getIssueIdFromFilename(f), 10))
163
- .filter(n => !isNaN(n))
161
+ const numbers = files
162
+ .map((f) => parseInt(getIssueIdFromFilename(f), 10))
163
+ .filter((n) => !Number.isNaN(n))
164
164
 
165
- const max = Math.max(...numbers, 0)
166
- return String(max + 1).padStart(4, '0')
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
- const files = await getIssueFiles()
174
- const paddedId = id.padStart(4, '0')
173
+ const files = await getIssueFiles()
174
+ const paddedId = id.padStart(4, '0')
175
175
 
176
- const file = files.find(
177
- f => f.startsWith(paddedId) || getIssueIdFromFilename(f) === paddedId
178
- )
176
+ const file = files.find(
177
+ (f) => f.startsWith(paddedId) || getIssueIdFromFilename(f) === paddedId,
178
+ )
179
179
 
180
- if (!file) return null
180
+ if (!file) return null
181
181
 
182
- const filepath = join(getIssuesDir(), file)
183
- const content = await readFile(filepath, 'utf-8')
184
- const { frontmatter, body } = parseFrontmatter(content)
182
+ const filepath = join(getIssuesDir(), file)
183
+ const content = await readFile(filepath, 'utf-8')
184
+ const { frontmatter, body } = parseFrontmatter(content)
185
185
 
186
- return {
187
- id: getIssueIdFromFilename(file),
188
- filename: file,
189
- frontmatter: frontmatter as IssueFrontmatter,
190
- content: body
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
- 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
- })
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
- 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)}
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
- await writeFile(join(getIssuesDir(), filename), content)
270
+ await writeFile(join(getIssuesDir(), filename), content)
271
271
 
272
- return {
273
- id: issueNumber,
274
- filename,
275
- frontmatter,
276
- content: '\n## Details\n\n<!-- Add detailed description here -->\n\n'
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
- id: string,
285
- input: UpdateIssueInput
284
+ id: string,
285
+ input: UpdateIssueInput,
286
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)}
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
- await writeFile(join(getIssuesDir(), issue.filename), content)
310
+ await writeFile(join(getIssuesDir(), issue.filename), content)
311
311
 
312
- return {
313
- ...issue,
314
- frontmatter: updatedFrontmatter
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
- return updateIssue(id, { status: 'closed' })
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
- return updateIssue(id, { status: 'open' })
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
- const issue = await getIssue(id)
336
+ const issue = await getIssue(id)
337
337
 
338
- if (!issue) {
339
- throw new Error(`Issue not found: ${id}`)
340
- }
338
+ if (!issue) {
339
+ throw new Error(`Issue not found: ${id}`)
340
+ }
341
341
 
342
- const { unlink } = await import('node:fs/promises')
343
- await unlink(join(getIssuesDir(), issue.filename))
342
+ const { unlink } = await import('node:fs/promises')
343
+ await unlink(join(getIssuesDir(), issue.filename))
344
344
  }