@miketromba/issy-core 0.3.0 → 0.5.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 +2 -1
- package/src/lib/autocomplete.ts +1 -1
- package/src/lib/index.ts +11 -0
- package/src/lib/issues.ts +210 -96
- package/src/lib/search.ts +23 -15
- package/src/lib/types.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miketromba/issy-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Issue storage, search, and parsing for issy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"date-fns": "^4.1.0",
|
|
32
|
+
"fractional-indexing": "^3.2.0",
|
|
32
33
|
"fuse.js": "^7.1.0"
|
|
33
34
|
}
|
|
34
35
|
}
|
package/src/lib/autocomplete.ts
CHANGED
|
@@ -37,7 +37,7 @@ const QUALIFIER_VALUES: Record<string, readonly string[]> = {
|
|
|
37
37
|
priority: ['high', 'medium', 'low'] as const,
|
|
38
38
|
scope: ['small', 'medium', 'large'] as const,
|
|
39
39
|
type: ['bug', 'improvement'] as const,
|
|
40
|
-
sort: ['priority', 'scope', 'created', 'updated', 'id'] as const,
|
|
40
|
+
sort: ['roadmap', 'priority', 'scope', 'created', 'updated', 'id'] as const,
|
|
41
41
|
// label values are dynamic and provided via existingLabels parameter
|
|
42
42
|
}
|
|
43
43
|
|
package/src/lib/index.ts
CHANGED
|
@@ -14,24 +14,35 @@ export { formatDisplayDate, formatFullDate } from './formatDate'
|
|
|
14
14
|
export {
|
|
15
15
|
autoDetectIssuesDir,
|
|
16
16
|
closeIssue,
|
|
17
|
+
computeOrderKey,
|
|
17
18
|
createIssue,
|
|
18
19
|
createSlug,
|
|
19
20
|
deleteIssue,
|
|
20
21
|
ensureIssuesDir,
|
|
21
22
|
findGitRoot,
|
|
22
23
|
findIssuesDirUpward,
|
|
24
|
+
findIssyDirUpward,
|
|
25
|
+
findLegacyIssuesDirUpward,
|
|
23
26
|
formatDate,
|
|
27
|
+
generateBatchOrderKeys,
|
|
24
28
|
generateFrontmatter,
|
|
25
29
|
getAllIssues,
|
|
26
30
|
getIssue,
|
|
27
31
|
getIssueFiles,
|
|
28
32
|
getIssueIdFromFilename,
|
|
29
33
|
getIssuesDir,
|
|
34
|
+
getIssyDir,
|
|
35
|
+
getNextIssue,
|
|
30
36
|
getNextIssueNumber,
|
|
37
|
+
getOnCloseContent,
|
|
38
|
+
getOpenIssuesByOrder,
|
|
39
|
+
hasLegacyIssuesDir,
|
|
31
40
|
parseFrontmatter,
|
|
32
41
|
reopenIssue,
|
|
33
42
|
resolveIssuesDir,
|
|
43
|
+
resolveIssyDir,
|
|
34
44
|
setIssuesDir,
|
|
45
|
+
setIssyDir,
|
|
35
46
|
updateIssue,
|
|
36
47
|
} from './issues'
|
|
37
48
|
// Query parser
|
package/src/lib/issues.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { existsSync } from 'node:fs'
|
|
7
7
|
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
8
8
|
import { dirname, join, resolve } from 'node:path'
|
|
9
|
+
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing'
|
|
9
10
|
import type {
|
|
10
11
|
CreateIssueInput,
|
|
11
12
|
Issue,
|
|
@@ -13,58 +14,85 @@ import type {
|
|
|
13
14
|
UpdateIssueInput,
|
|
14
15
|
} from './types'
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
let issyDir: string | null = null
|
|
17
18
|
let issuesDir: string | null = null
|
|
18
19
|
|
|
20
|
+
export function setIssyDir(dir: string) {
|
|
21
|
+
issyDir = dir
|
|
22
|
+
issuesDir = join(dir, 'issues')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getIssyDir(): string {
|
|
26
|
+
if (!issyDir) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'Issy directory not initialized. Call resolveIssyDir() first.',
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
return issyDir
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
/**
|
|
20
|
-
*
|
|
35
|
+
* @deprecated Use setIssyDir() instead
|
|
21
36
|
*/
|
|
22
37
|
export function setIssuesDir(dir: string) {
|
|
23
38
|
issuesDir = dir
|
|
24
39
|
}
|
|
25
40
|
|
|
26
|
-
/**
|
|
27
|
-
* Get the issues directory path
|
|
28
|
-
*/
|
|
29
41
|
export function getIssuesDir(): string {
|
|
30
42
|
if (!issuesDir) {
|
|
31
43
|
throw new Error(
|
|
32
|
-
'Issues directory not initialized. Call
|
|
44
|
+
'Issues directory not initialized. Call resolveIssyDir() first.',
|
|
33
45
|
)
|
|
34
46
|
}
|
|
35
47
|
return issuesDir
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
/**
|
|
39
|
-
* Ensure issues directory exists
|
|
40
|
-
*/
|
|
41
50
|
export async function ensureIssuesDir(): Promise<void> {
|
|
42
51
|
await mkdir(getIssuesDir(), { recursive: true })
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
/**
|
|
46
|
-
* Try to find .
|
|
47
|
-
* Returns the path if found, or null if not found.
|
|
55
|
+
* Try to find .issy directory by walking up from the given path.
|
|
48
56
|
*/
|
|
49
|
-
export function
|
|
57
|
+
export function findIssyDirUpward(fromPath: string): string | null {
|
|
58
|
+
let current = resolve(fromPath)
|
|
59
|
+
for (let i = 0; i < 20; i++) {
|
|
60
|
+
const candidate = join(current, '.issy')
|
|
61
|
+
if (existsSync(candidate)) {
|
|
62
|
+
return candidate
|
|
63
|
+
}
|
|
64
|
+
const parent = dirname(current)
|
|
65
|
+
if (parent === current) break
|
|
66
|
+
current = parent
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Try to find legacy .issues directory by walking up from the given path.
|
|
73
|
+
* Used for migration detection.
|
|
74
|
+
*/
|
|
75
|
+
export function findLegacyIssuesDirUpward(fromPath: string): string | null {
|
|
50
76
|
let current = resolve(fromPath)
|
|
51
|
-
// Walk up to 20 levels (reasonable for most project structures)
|
|
52
77
|
for (let i = 0; i < 20; i++) {
|
|
53
78
|
const candidate = join(current, '.issues')
|
|
54
79
|
if (existsSync(candidate)) {
|
|
55
80
|
return candidate
|
|
56
81
|
}
|
|
57
82
|
const parent = dirname(current)
|
|
58
|
-
if (parent === current) break
|
|
83
|
+
if (parent === current) break
|
|
59
84
|
current = parent
|
|
60
85
|
}
|
|
61
86
|
return null
|
|
62
87
|
}
|
|
63
88
|
|
|
64
89
|
/**
|
|
65
|
-
*
|
|
66
|
-
* Returns the directory containing .git, or null if not in a git repo.
|
|
90
|
+
* @deprecated Use findIssyDirUpward() instead
|
|
67
91
|
*/
|
|
92
|
+
export function findIssuesDirUpward(fromPath: string): string | null {
|
|
93
|
+
return findIssyDirUpward(fromPath) ?? findLegacyIssuesDirUpward(fromPath)
|
|
94
|
+
}
|
|
95
|
+
|
|
68
96
|
export function findGitRoot(fromPath: string): string | null {
|
|
69
97
|
let current = resolve(fromPath)
|
|
70
98
|
for (let i = 0; i < 20; i++) {
|
|
@@ -73,64 +101,78 @@ export function findGitRoot(fromPath: string): string | null {
|
|
|
73
101
|
return current
|
|
74
102
|
}
|
|
75
103
|
const parent = dirname(current)
|
|
76
|
-
if (parent === current) break
|
|
104
|
+
if (parent === current) break
|
|
77
105
|
current = parent
|
|
78
106
|
}
|
|
79
107
|
return null
|
|
80
108
|
}
|
|
81
109
|
|
|
82
110
|
/**
|
|
83
|
-
* Resolve the
|
|
84
|
-
* 1.
|
|
85
|
-
* 2. Walk up from
|
|
86
|
-
* 3. If in a git repo, use .
|
|
87
|
-
* 4. Fall back to
|
|
111
|
+
* Resolve the .issy directory using the following priority:
|
|
112
|
+
* 1. ISSY_DIR env var (explicit override)
|
|
113
|
+
* 2. Walk up from cwd to find existing .issy directory
|
|
114
|
+
* 3. If in a git repo, use .issy at the repo root
|
|
115
|
+
* 4. Fall back to cwd/.issy
|
|
88
116
|
*
|
|
89
|
-
*
|
|
117
|
+
* Also detects legacy .issues/ directories and warns.
|
|
90
118
|
*/
|
|
91
|
-
export function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
setIssuesDir(dir)
|
|
119
|
+
export function resolveIssyDir(): string {
|
|
120
|
+
if (process.env.ISSY_DIR) {
|
|
121
|
+
const dir = resolve(process.env.ISSY_DIR)
|
|
122
|
+
setIssyDir(dir)
|
|
96
123
|
return dir
|
|
97
124
|
}
|
|
98
125
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const found =
|
|
126
|
+
const startDir = process.env.ISSY_ROOT || process.cwd()
|
|
127
|
+
|
|
128
|
+
const found = findIssyDirUpward(startDir)
|
|
102
129
|
if (found) {
|
|
103
|
-
|
|
130
|
+
setIssyDir(found)
|
|
104
131
|
return found
|
|
105
132
|
}
|
|
106
133
|
|
|
107
|
-
// 3. If in a git repo, use .issues at the repo root
|
|
108
134
|
const gitRoot = findGitRoot(startDir)
|
|
109
135
|
if (gitRoot) {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
return
|
|
136
|
+
const gitIssyDir = join(gitRoot, '.issy')
|
|
137
|
+
setIssyDir(gitIssyDir)
|
|
138
|
+
return gitIssyDir
|
|
113
139
|
}
|
|
114
140
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
setIssuesDir(fallback)
|
|
141
|
+
const fallback = join(resolve(startDir), '.issy')
|
|
142
|
+
setIssyDir(fallback)
|
|
118
143
|
return fallback
|
|
119
144
|
}
|
|
120
145
|
|
|
121
146
|
/**
|
|
122
|
-
*
|
|
123
|
-
|
|
147
|
+
* @deprecated Use resolveIssyDir() instead
|
|
148
|
+
*/
|
|
149
|
+
export function resolveIssuesDir(): string {
|
|
150
|
+
resolveIssyDir()
|
|
151
|
+
return getIssuesDir()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @deprecated Use resolveIssyDir() instead
|
|
124
156
|
*/
|
|
125
157
|
export function autoDetectIssuesDir(fromPath: string): string {
|
|
126
|
-
const found =
|
|
127
|
-
if (found)
|
|
128
|
-
|
|
158
|
+
const found = findIssyDirUpward(fromPath)
|
|
159
|
+
if (found) {
|
|
160
|
+
setIssyDir(found)
|
|
161
|
+
return getIssuesDir()
|
|
162
|
+
}
|
|
163
|
+
throw new Error('Could not find .issy directory')
|
|
129
164
|
}
|
|
130
165
|
|
|
131
166
|
/**
|
|
132
|
-
*
|
|
167
|
+
* Check if a legacy .issues/ directory exists (not inside .issy/)
|
|
133
168
|
*/
|
|
169
|
+
export function hasLegacyIssuesDir(): string | null {
|
|
170
|
+
const startDir = process.env.ISSY_ROOT || process.cwd()
|
|
171
|
+
return findLegacyIssuesDirUpward(startDir)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- Frontmatter ---
|
|
175
|
+
|
|
134
176
|
export function parseFrontmatter(content: string): {
|
|
135
177
|
frontmatter: Partial<IssueFrontmatter>
|
|
136
178
|
body: string
|
|
@@ -155,9 +197,6 @@ export function parseFrontmatter(content: string): {
|
|
|
155
197
|
return { frontmatter, body }
|
|
156
198
|
}
|
|
157
199
|
|
|
158
|
-
/**
|
|
159
|
-
* Generate YAML front matter string from issue data
|
|
160
|
-
*/
|
|
161
200
|
export function generateFrontmatter(data: IssueFrontmatter): string {
|
|
162
201
|
const lines = ['---']
|
|
163
202
|
lines.push(`title: ${data.title}`)
|
|
@@ -171,6 +210,9 @@ export function generateFrontmatter(data: IssueFrontmatter): string {
|
|
|
171
210
|
lines.push(`labels: ${data.labels}`)
|
|
172
211
|
}
|
|
173
212
|
lines.push(`status: ${data.status}`)
|
|
213
|
+
if (data.order) {
|
|
214
|
+
lines.push(`order: ${data.order}`)
|
|
215
|
+
}
|
|
174
216
|
lines.push(`created: ${data.created}`)
|
|
175
217
|
if (data.updated) {
|
|
176
218
|
lines.push(`updated: ${data.updated}`)
|
|
@@ -179,17 +221,13 @@ export function generateFrontmatter(data: IssueFrontmatter): string {
|
|
|
179
221
|
return lines.join('\n')
|
|
180
222
|
}
|
|
181
223
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
*/
|
|
224
|
+
// --- File operations ---
|
|
225
|
+
|
|
185
226
|
export function getIssueIdFromFilename(filename: string): string {
|
|
186
227
|
const match = filename.match(/^(\d+)-/)
|
|
187
228
|
return match ? match[1] : filename.replace('.md', '')
|
|
188
229
|
}
|
|
189
230
|
|
|
190
|
-
/**
|
|
191
|
-
* Create URL-friendly slug from title
|
|
192
|
-
*/
|
|
193
231
|
export function createSlug(title: string): string {
|
|
194
232
|
return title
|
|
195
233
|
.toLowerCase()
|
|
@@ -199,17 +237,10 @@ export function createSlug(title: string): string {
|
|
|
199
237
|
.slice(0, 50)
|
|
200
238
|
}
|
|
201
239
|
|
|
202
|
-
/**
|
|
203
|
-
* Format date as ISO 8601 timestamp (YYYY-MM-DDTHH:mm:ss)
|
|
204
|
-
* This provides second-level precision for better sorting
|
|
205
|
-
*/
|
|
206
240
|
export function formatDate(date: Date = new Date()): string {
|
|
207
241
|
return date.toISOString().slice(0, 19)
|
|
208
242
|
}
|
|
209
243
|
|
|
210
|
-
/**
|
|
211
|
-
* Get all issue filenames from the issues directory
|
|
212
|
-
*/
|
|
213
244
|
export async function getIssueFiles(): Promise<string[]> {
|
|
214
245
|
try {
|
|
215
246
|
const files = await readdir(getIssuesDir())
|
|
@@ -219,9 +250,6 @@ export async function getIssueFiles(): Promise<string[]> {
|
|
|
219
250
|
}
|
|
220
251
|
}
|
|
221
252
|
|
|
222
|
-
/**
|
|
223
|
-
* Get the next available issue number
|
|
224
|
-
*/
|
|
225
253
|
export async function getNextIssueNumber(): Promise<string> {
|
|
226
254
|
const files = await getIssueFiles()
|
|
227
255
|
if (files.length === 0) return '0001'
|
|
@@ -234,9 +262,6 @@ export async function getNextIssueNumber(): Promise<string> {
|
|
|
234
262
|
return String(max + 1).padStart(4, '0')
|
|
235
263
|
}
|
|
236
264
|
|
|
237
|
-
/**
|
|
238
|
-
* Load a single issue by ID
|
|
239
|
-
*/
|
|
240
265
|
export async function getIssue(id: string): Promise<Issue | null> {
|
|
241
266
|
const files = await getIssueFiles()
|
|
242
267
|
const paddedId = id.padStart(4, '0')
|
|
@@ -259,9 +284,6 @@ export async function getIssue(id: string): Promise<Issue | null> {
|
|
|
259
284
|
}
|
|
260
285
|
}
|
|
261
286
|
|
|
262
|
-
/**
|
|
263
|
-
* Load all issues
|
|
264
|
-
*/
|
|
265
287
|
export async function getAllIssues(): Promise<Issue[]> {
|
|
266
288
|
const files = await getIssueFiles()
|
|
267
289
|
const issues: Issue[] = []
|
|
@@ -279,23 +301,102 @@ export async function getAllIssues(): Promise<Issue[]> {
|
|
|
279
301
|
})
|
|
280
302
|
}
|
|
281
303
|
|
|
282
|
-
//
|
|
283
|
-
const priorityOrder: Record<string, number> = { high: 0, medium: 1, low: 2 }
|
|
304
|
+
// Default sort: roadmap order for issues that have it, then by ID
|
|
284
305
|
return issues.sort((a, b) => {
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
if (
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
// Within same priority, sort by ID descending (newest first)
|
|
292
|
-
return b.id.localeCompare(a.id)
|
|
306
|
+
const orderA = a.frontmatter.order
|
|
307
|
+
const orderB = b.frontmatter.order
|
|
308
|
+
if (orderA && orderB) return orderA < orderB ? -1 : orderA > orderB ? 1 : 0
|
|
309
|
+
if (orderA && !orderB) return -1
|
|
310
|
+
if (!orderA && orderB) return 1
|
|
311
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
|
293
312
|
})
|
|
294
313
|
}
|
|
295
314
|
|
|
315
|
+
// --- Roadmap ordering ---
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Get all open issues sorted by roadmap order.
|
|
319
|
+
*/
|
|
320
|
+
export async function getOpenIssuesByOrder(): Promise<Issue[]> {
|
|
321
|
+
const allIssues = await getAllIssues()
|
|
322
|
+
return allIssues.filter((i) => i.frontmatter.status === 'open')
|
|
323
|
+
}
|
|
324
|
+
|
|
296
325
|
/**
|
|
297
|
-
*
|
|
326
|
+
* Compute a fractional index key for inserting relative to existing issues.
|
|
327
|
+
*
|
|
328
|
+
* @param openIssues - Open issues already sorted by order
|
|
329
|
+
* @param options - positioning: before/after target ID, or first/last boolean
|
|
330
|
+
* @param excludeId - Exclude this issue from consideration (for repositioning)
|
|
298
331
|
*/
|
|
332
|
+
export function computeOrderKey(
|
|
333
|
+
openIssues: Issue[],
|
|
334
|
+
options: {
|
|
335
|
+
before?: string
|
|
336
|
+
after?: string
|
|
337
|
+
first?: boolean
|
|
338
|
+
last?: boolean
|
|
339
|
+
},
|
|
340
|
+
excludeId?: string,
|
|
341
|
+
): string {
|
|
342
|
+
const issues = excludeId
|
|
343
|
+
? openIssues.filter((i) => i.id !== excludeId.padStart(4, '0'))
|
|
344
|
+
: openIssues
|
|
345
|
+
|
|
346
|
+
if (options.first) {
|
|
347
|
+
if (issues.length === 0) return generateKeyBetween(null, null)
|
|
348
|
+
const firstOrder = issues[0].frontmatter.order || null
|
|
349
|
+
return generateKeyBetween(null, firstOrder)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (options.last) {
|
|
353
|
+
if (issues.length === 0) return generateKeyBetween(null, null)
|
|
354
|
+
const lastOrder = issues[issues.length - 1].frontmatter.order || null
|
|
355
|
+
return generateKeyBetween(lastOrder, null)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (options.after) {
|
|
359
|
+
const targetId = options.after.padStart(4, '0')
|
|
360
|
+
const idx = issues.findIndex((i) => i.id === targetId)
|
|
361
|
+
if (idx === -1)
|
|
362
|
+
throw new Error(
|
|
363
|
+
`Issue #${options.after} not found among open issues. The --after target must be an open issue.`,
|
|
364
|
+
)
|
|
365
|
+
const afterOrder = issues[idx].frontmatter.order || null
|
|
366
|
+
const nextOrder =
|
|
367
|
+
idx + 1 < issues.length ? issues[idx + 1].frontmatter.order || null : null
|
|
368
|
+
return generateKeyBetween(afterOrder, nextOrder)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (options.before) {
|
|
372
|
+
const targetId = options.before.padStart(4, '0')
|
|
373
|
+
const idx = issues.findIndex((i) => i.id === targetId)
|
|
374
|
+
if (idx === -1)
|
|
375
|
+
throw new Error(
|
|
376
|
+
`Issue #${options.before} not found among open issues. The --before target must be an open issue.`,
|
|
377
|
+
)
|
|
378
|
+
const beforeOrder = issues[idx].frontmatter.order || null
|
|
379
|
+
const prevOrder = idx > 0 ? issues[idx - 1].frontmatter.order || null : null
|
|
380
|
+
return generateKeyBetween(prevOrder, beforeOrder)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// No before/after: append at end (used for first issue or migration)
|
|
384
|
+
if (issues.length === 0) {
|
|
385
|
+
return generateKeyBetween(null, null)
|
|
386
|
+
}
|
|
387
|
+
const lastOrder = issues[issues.length - 1].frontmatter.order || null
|
|
388
|
+
return generateKeyBetween(lastOrder, null)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Generate evenly-spaced order keys for a batch of items (used during migration).
|
|
393
|
+
*/
|
|
394
|
+
export function generateBatchOrderKeys(count: number): string[] {
|
|
395
|
+
return generateNKeysBetween(null, null, count)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// --- CRUD ---
|
|
399
|
+
|
|
299
400
|
export async function createIssue(input: CreateIssueInput): Promise<Issue> {
|
|
300
401
|
await ensureIssuesDir()
|
|
301
402
|
if (!input.title) {
|
|
@@ -330,6 +431,7 @@ export async function createIssue(input: CreateIssueInput): Promise<Issue> {
|
|
|
330
431
|
type,
|
|
331
432
|
labels: input.labels || undefined,
|
|
332
433
|
status: 'open',
|
|
434
|
+
order: input.order || undefined,
|
|
333
435
|
created: formatDate(),
|
|
334
436
|
}
|
|
335
437
|
|
|
@@ -351,9 +453,6 @@ export async function createIssue(input: CreateIssueInput): Promise<Issue> {
|
|
|
351
453
|
}
|
|
352
454
|
}
|
|
353
455
|
|
|
354
|
-
/**
|
|
355
|
-
* Update an existing issue
|
|
356
|
-
*/
|
|
357
456
|
export async function updateIssue(
|
|
358
457
|
id: string,
|
|
359
458
|
input: UpdateIssueInput,
|
|
@@ -364,7 +463,6 @@ export async function updateIssue(
|
|
|
364
463
|
throw new Error(`Issue not found: ${id}`)
|
|
365
464
|
}
|
|
366
465
|
|
|
367
|
-
// Update fields
|
|
368
466
|
const updatedFrontmatter: IssueFrontmatter = {
|
|
369
467
|
...issue.frontmatter,
|
|
370
468
|
...(input.title && { title: input.title }),
|
|
@@ -376,6 +474,7 @@ export async function updateIssue(
|
|
|
376
474
|
labels: input.labels || undefined,
|
|
377
475
|
}),
|
|
378
476
|
...(input.status && { status: input.status }),
|
|
477
|
+
...(input.order && { order: input.order }),
|
|
379
478
|
updated: formatDate(),
|
|
380
479
|
}
|
|
381
480
|
|
|
@@ -390,23 +489,14 @@ ${issue.content}`
|
|
|
390
489
|
}
|
|
391
490
|
}
|
|
392
491
|
|
|
393
|
-
/**
|
|
394
|
-
* Close an issue
|
|
395
|
-
*/
|
|
396
492
|
export async function closeIssue(id: string): Promise<Issue> {
|
|
397
493
|
return updateIssue(id, { status: 'closed' })
|
|
398
494
|
}
|
|
399
495
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
*/
|
|
403
|
-
export async function reopenIssue(id: string): Promise<Issue> {
|
|
404
|
-
return updateIssue(id, { status: 'open' })
|
|
496
|
+
export async function reopenIssue(id: string, order?: string): Promise<Issue> {
|
|
497
|
+
return updateIssue(id, { status: 'open', order })
|
|
405
498
|
}
|
|
406
499
|
|
|
407
|
-
/**
|
|
408
|
-
* Delete an issue permanently
|
|
409
|
-
*/
|
|
410
500
|
export async function deleteIssue(id: string): Promise<void> {
|
|
411
501
|
const issue = await getIssue(id)
|
|
412
502
|
|
|
@@ -417,3 +507,27 @@ export async function deleteIssue(id: string): Promise<void> {
|
|
|
417
507
|
const { unlink } = await import('node:fs/promises')
|
|
418
508
|
await unlink(join(getIssuesDir(), issue.filename))
|
|
419
509
|
}
|
|
510
|
+
|
|
511
|
+
// --- Hooks ---
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Read the on_close.md hook content if it exists.
|
|
515
|
+
*/
|
|
516
|
+
export async function getOnCloseContent(): Promise<string | null> {
|
|
517
|
+
try {
|
|
518
|
+
const onClosePath = join(getIssyDir(), 'on_close.md')
|
|
519
|
+
return await readFile(onClosePath, 'utf-8')
|
|
520
|
+
} catch {
|
|
521
|
+
return null
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// --- Next issue ---
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Get the next issue to work on: the first open issue in roadmap order.
|
|
529
|
+
*/
|
|
530
|
+
export async function getNextIssue(): Promise<Issue | null> {
|
|
531
|
+
const openIssues = await getOpenIssuesByOrder()
|
|
532
|
+
return openIssues.length > 0 ? openIssues[0] : null
|
|
533
|
+
}
|
package/src/lib/search.ts
CHANGED
|
@@ -124,12 +124,22 @@ export function filterAndSearchIssues(
|
|
|
124
124
|
* Sort issues by the specified sort option
|
|
125
125
|
*
|
|
126
126
|
* @param issues - Array of issues to sort (modified in place)
|
|
127
|
-
* @param sortBy - Sort option: "priority", "scope", "created", "updated", or "id"
|
|
127
|
+
* @param sortBy - Sort option: "roadmap", "priority", "scope", "created", "updated", or "id"
|
|
128
128
|
*/
|
|
129
129
|
function sortIssues(issues: Issue[], sortBy: string): void {
|
|
130
130
|
const sortOption = sortBy.toLowerCase()
|
|
131
131
|
|
|
132
|
-
if (sortOption === '
|
|
132
|
+
if (sortOption === 'roadmap') {
|
|
133
|
+
issues.sort((a, b) => {
|
|
134
|
+
const orderA = a.frontmatter.order
|
|
135
|
+
const orderB = b.frontmatter.order
|
|
136
|
+
if (orderA && orderB)
|
|
137
|
+
return orderA < orderB ? -1 : orderA > orderB ? 1 : 0
|
|
138
|
+
if (orderA && !orderB) return -1
|
|
139
|
+
if (!orderA && orderB) return 1
|
|
140
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
|
141
|
+
})
|
|
142
|
+
} else if (sortOption === 'priority') {
|
|
133
143
|
// Sort by priority (high → medium → low), then by ID (newest first)
|
|
134
144
|
const priorityOrder: Record<string, number> = {
|
|
135
145
|
high: 0,
|
|
@@ -188,17 +198,15 @@ function sortIssues(issues: Issue[], sortBy: string): void {
|
|
|
188
198
|
// Sort by issue ID (newest first)
|
|
189
199
|
issues.sort((a, b) => b.id.localeCompare(a.id))
|
|
190
200
|
} else {
|
|
191
|
-
// Invalid sort option - default to
|
|
192
|
-
const priorityOrder: Record<string, number> = {
|
|
193
|
-
high: 0,
|
|
194
|
-
medium: 1,
|
|
195
|
-
low: 2,
|
|
196
|
-
}
|
|
201
|
+
// Invalid sort option - default to roadmap order
|
|
197
202
|
issues.sort((a, b) => {
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
if (
|
|
201
|
-
|
|
203
|
+
const orderA = a.frontmatter.order
|
|
204
|
+
const orderB = b.frontmatter.order
|
|
205
|
+
if (orderA && orderB)
|
|
206
|
+
return orderA < orderB ? -1 : orderA > orderB ? 1 : 0
|
|
207
|
+
if (orderA && !orderB) return -1
|
|
208
|
+
if (!orderA && orderB) return 1
|
|
209
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
|
202
210
|
})
|
|
203
211
|
}
|
|
204
212
|
}
|
|
@@ -211,12 +219,12 @@ function sortIssues(issues: Issue[], sortBy: string): void {
|
|
|
211
219
|
* - `priority:high` / `priority:medium` / `priority:low` - filters by priority
|
|
212
220
|
* - `type:bug` / `type:improvement` - filters by type
|
|
213
221
|
* - `label:x` - filters by label (case-insensitive partial match)
|
|
214
|
-
* - `sort:priority` / `sort:created` / `sort:created-asc` / `sort:updated` / `sort:id` - sorts results
|
|
222
|
+
* - `sort:roadmap` / `sort:priority` / `sort:created` / `sort:created-asc` / `sort:updated` / `sort:id` - sorts results
|
|
215
223
|
*
|
|
216
224
|
* Any remaining free text after qualifiers triggers fuzzy search across title,
|
|
217
225
|
* description, labels, and content. Results are sorted by relevance when search
|
|
218
226
|
* text is present. When no search text is provided, results are sorted by the
|
|
219
|
-
* `sort:` qualifier (defaults to
|
|
227
|
+
* `sort:` qualifier (defaults to roadmap if not specified). ID prefix matching
|
|
220
228
|
* is supported (e.g., "1" matches #0001).
|
|
221
229
|
*
|
|
222
230
|
* Invalid qualifier values are ignored (issue passes filter).
|
|
@@ -326,7 +334,7 @@ export function filterByQuery(issues: Issue[], query: string): Issue[] {
|
|
|
326
334
|
|
|
327
335
|
// Apply sorting if no search text (search text uses relevance sorting)
|
|
328
336
|
if (!parsed.searchText.trim()) {
|
|
329
|
-
const sortBy = parsed.qualifiers.sort?.toLowerCase() || '
|
|
337
|
+
const sortBy = parsed.qualifiers.sort?.toLowerCase() || 'roadmap'
|
|
330
338
|
sortIssues(result, sortBy)
|
|
331
339
|
}
|
|
332
340
|
|
package/src/lib/types.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface IssueFrontmatter {
|
|
|
10
10
|
type: 'bug' | 'improvement'
|
|
11
11
|
labels?: string
|
|
12
12
|
status: 'open' | 'closed'
|
|
13
|
+
order?: string
|
|
13
14
|
created: string
|
|
14
15
|
updated?: string
|
|
15
16
|
}
|
|
@@ -36,6 +37,7 @@ export interface CreateIssueInput {
|
|
|
36
37
|
scope?: 'small' | 'medium' | 'large'
|
|
37
38
|
type?: 'bug' | 'improvement'
|
|
38
39
|
labels?: string
|
|
40
|
+
order?: string
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export interface UpdateIssueInput {
|
|
@@ -46,4 +48,5 @@ export interface UpdateIssueInput {
|
|
|
46
48
|
type?: 'bug' | 'improvement'
|
|
47
49
|
labels?: string
|
|
48
50
|
status?: 'open' | 'closed'
|
|
51
|
+
order?: string
|
|
49
52
|
}
|