issy 0.4.0 → 0.5.2

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/bin/issy CHANGED
@@ -4,8 +4,10 @@ import {
4
4
  existsSync,
5
5
  mkdirSync,
6
6
  readdirSync,
7
+ readFileSync,
7
8
  writeFileSync,
8
- readFileSync
9
+ renameSync,
10
+ cpSync
9
11
  } from 'node:fs'
10
12
  import { join, resolve } from 'node:path'
11
13
  import { fileURLToPath } from 'node:url'
@@ -23,7 +25,7 @@ const here = resolve(fileURLToPath(import.meta.url), '..')
23
25
  const pkgPath = resolve(here, '..', 'package.json')
24
26
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
25
27
 
26
- const notifier = updateNotifier({ pkg, updateCheckInterval: 1000 * 60 }) // 1 minute
28
+ const notifier = updateNotifier({ pkg, updateCheckInterval: 1000 * 60 })
27
29
 
28
30
  if (notifier.update) {
29
31
  const scriptPath = resolve(fileURLToPath(import.meta.url))
@@ -45,6 +47,8 @@ const cliCommands = new Set([
45
47
  'create',
46
48
  'update',
47
49
  'close',
50
+ 'reopen',
51
+ 'next',
48
52
  'help',
49
53
  '--help',
50
54
  '-h'
@@ -57,10 +61,26 @@ if (args[0] === '--version' || args[0] === '-v') {
57
61
  }
58
62
 
59
63
  /**
60
- * Find .issues directory by walking up from the given path.
61
- * Returns the path if found, or null if not found.
64
+ * Find .issy directory by walking up from the given path.
62
65
  */
63
- function findIssuesDirUpward(fromPath) {
66
+ function findIssyDirUpward(fromPath) {
67
+ let current = resolve(fromPath)
68
+ for (let i = 0; i < 20; i++) {
69
+ const candidate = join(current, '.issy')
70
+ if (existsSync(candidate)) {
71
+ return candidate
72
+ }
73
+ const parent = dirname(current)
74
+ if (parent === current) break
75
+ current = parent
76
+ }
77
+ return null
78
+ }
79
+
80
+ /**
81
+ * Find legacy .issues directory by walking up (for migration detection).
82
+ */
83
+ function findLegacyIssuesDir(fromPath) {
64
84
  let current = resolve(fromPath)
65
85
  for (let i = 0; i < 20; i++) {
66
86
  const candidate = join(current, '.issues')
@@ -76,7 +96,6 @@ function findIssuesDirUpward(fromPath) {
76
96
 
77
97
  /**
78
98
  * Find the git repository root by walking up from the given path.
79
- * Returns the directory containing .git, or null if not in a git repo.
80
99
  */
81
100
  function findGitRoot(fromPath) {
82
101
  let current = resolve(fromPath)
@@ -93,47 +112,154 @@ function findGitRoot(fromPath) {
93
112
  }
94
113
 
95
114
  /**
96
- * Resolve the issues directory using priority:
97
- * 1. ISSUES_DIR env var (explicit override)
98
- * 2. Walk up from ISSUES_ROOT or cwd to find existing .issues
99
- * 3. If in a git repo, use .issues at the repo root
100
- * 4. Fall back to creating .issues in ISSUES_ROOT or cwd
115
+ * Resolve the .issy directory using priority:
116
+ * 1. ISSY_DIR env var (explicit override)
117
+ * 2. Walk up from cwd to find existing .issy
118
+ * 3. If in a git repo, use .issy at the repo root
119
+ * 4. Fall back to cwd/.issy
101
120
  */
102
- function resolveIssuesDir() {
103
- // 1. Explicit override
104
- if (process.env.ISSUES_DIR) {
105
- return resolve(process.env.ISSUES_DIR)
121
+ function resolveIssyDir() {
122
+ if (process.env.ISSY_DIR) {
123
+ return resolve(process.env.ISSY_DIR)
106
124
  }
107
125
 
108
- // 2. Try to find existing .issues by walking up
109
- const startDir = process.env.ISSUES_ROOT || process.cwd()
110
- const found = findIssuesDirUpward(startDir)
126
+ const startDir = process.env.ISSY_ROOT || process.cwd()
127
+ const found = findIssyDirUpward(startDir)
111
128
  if (found) {
112
129
  return found
113
130
  }
114
131
 
115
- // 3. If in a git repo, use .issues at the repo root
116
132
  const gitRoot = findGitRoot(startDir)
117
133
  if (gitRoot) {
118
- return join(gitRoot, '.issues')
134
+ return join(gitRoot, '.issy')
119
135
  }
120
136
 
121
- // 4. Fall back to creating in start directory
122
- return join(resolve(startDir), '.issues')
137
+ return join(resolve(startDir), '.issy')
123
138
  }
124
139
 
125
- const issuesDir = resolveIssuesDir()
140
+ const issyDir = resolveIssyDir()
141
+ const issuesDir = join(issyDir, 'issues')
142
+
126
143
  // Set env vars for downstream consumers
127
- process.env.ISSUES_DIR = issuesDir
128
- // Set ISSUES_ROOT to the parent of .issues for consistency
129
- process.env.ISSUES_ROOT = dirname(issuesDir)
144
+ process.env.ISSY_DIR = issyDir
145
+ process.env.ISSY_ROOT = dirname(issyDir)
146
+
147
+ // Detect legacy .issues/ directory and warn (unless running migrate)
148
+ const legacyDir = findLegacyIssuesDir(process.env.ISSY_ROOT || process.cwd())
149
+ if (legacyDir && args[0] !== 'migrate' && args[0] !== 'init') {
150
+ // Only warn if .issy doesn't exist yet (haven't migrated)
151
+ if (!existsSync(issyDir)) {
152
+ console.warn(`⚠️ Legacy .issues/ directory detected at ${legacyDir}`)
153
+ console.warn(` Run "issy migrate" to upgrade to the new .issy/ structure.\n`)
154
+ }
155
+ }
156
+
157
+ // --- Handle commands ---
158
+
159
+ if (args[0] === 'migrate') {
160
+ const startDir = process.env.ISSY_ROOT || process.cwd()
161
+ const legacy = findLegacyIssuesDir(startDir)
162
+
163
+ if (!legacy) {
164
+ console.log('No legacy .issues/ directory found. Nothing to migrate.')
165
+ process.exit(0)
166
+ }
167
+
168
+ if (existsSync(issyDir) && existsSync(issuesDir)) {
169
+ console.log('.issy/issues/ already exists. Migration may have already been completed.')
170
+ process.exit(1)
171
+ }
172
+
173
+ console.log(`Migrating ${legacy} → ${issuesDir}`)
174
+
175
+ // Create .issy/issues/
176
+ mkdirSync(issuesDir, { recursive: true })
177
+
178
+ // Copy issue files
179
+ const files = readdirSync(legacy).filter(f => f.endsWith('.md') && /^\d{4}-/.test(f))
180
+
181
+ // We'll use fractional-indexing to assign initial order keys to open issues.
182
+ // Since we can't import ESM from the bundled core here easily,
183
+ // we assign order keys inline using the same algorithm.
184
+ // Import dynamically from the built core.
185
+ let generateNKeysBetween
186
+ try {
187
+ const fi = await import('fractional-indexing')
188
+ generateNKeysBetween = fi.generateNKeysBetween
189
+ } catch {
190
+ console.error('Failed to load fractional-indexing. Please ensure dependencies are installed.')
191
+ process.exit(1)
192
+ }
193
+
194
+ // Parse frontmatter from each file and sort by ID
195
+ const issueData = files.map(f => {
196
+ const content = readFileSync(join(legacy, f), 'utf-8')
197
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
198
+ const frontmatter = {}
199
+ if (match) {
200
+ for (const line of match[1].split('\n')) {
201
+ const colonIdx = line.indexOf(':')
202
+ if (colonIdx > 0) {
203
+ frontmatter[line.slice(0, colonIdx).trim()] = line.slice(colonIdx + 1).trim()
204
+ }
205
+ }
206
+ }
207
+ return { filename: f, content, frontmatter }
208
+ }).sort((a, b) => a.filename.localeCompare(b.filename))
209
+
210
+ // Assign order keys to open issues
211
+ const openIssues = issueData.filter(i => i.frontmatter.status === 'open')
212
+ const orderKeys = generateNKeysBetween(null, null, openIssues.length)
213
+
214
+ const orderMap = new Map()
215
+ openIssues.forEach((issue, idx) => {
216
+ orderMap.set(issue.filename, orderKeys[idx])
217
+ })
218
+
219
+ // Write migrated files with order keys
220
+ for (const issue of issueData) {
221
+ let content = issue.content
222
+ const orderKey = orderMap.get(issue.filename)
223
+
224
+ if (orderKey) {
225
+ // Insert order field before the status line
226
+ content = content.replace(
227
+ /^(---\n[\s\S]*?)(status: \w+)/m,
228
+ `$1$2\norder: ${orderKey}`
229
+ )
230
+ }
231
+
232
+ writeFileSync(join(issuesDir, issue.filename), content)
233
+ }
234
+
235
+ // Copy any non-issue files (e.g., README)
236
+ const otherFiles = readdirSync(legacy).filter(f => !files.includes(f))
237
+ for (const f of otherFiles) {
238
+ const src = join(legacy, f)
239
+ const dest = join(issuesDir, f)
240
+ try {
241
+ cpSync(src, dest, { recursive: true })
242
+ } catch { /* skip if can't copy */ }
243
+ }
244
+
245
+ // Remove legacy directory
246
+ const { rmSync } = await import('node:fs')
247
+ rmSync(legacy, { recursive: true })
248
+
249
+ console.log(`✅ Migrated ${files.length} issue(s) to ${issuesDir}`)
250
+ if (openIssues.length > 0) {
251
+ console.log(` Assigned roadmap order to ${openIssues.length} open issue(s).`)
252
+ }
253
+ console.log(` Removed ${legacy}`)
254
+ process.exit(0)
255
+ }
130
256
 
131
257
  if (cliCommands.has(args[0] || '')) {
132
258
  const here = resolve(fileURLToPath(import.meta.url), '..')
133
259
  const entry = resolve(here, '..', 'dist', 'cli.js')
134
260
  process.argv = [process.argv[0], process.argv[1], ...args]
135
261
  const cli = await import(entry)
136
- await cli.ready // Wait for main() to complete before exiting
262
+ await cli.ready
137
263
  process.exit(0)
138
264
  }
139
265
 
@@ -145,18 +271,23 @@ if (portIdx >= 0 && args[portIdx + 1]) {
145
271
  const shouldInitOnly = args.includes('init')
146
272
  const shouldSeed = args.includes('--seed')
147
273
 
148
- // Only create .issues directory on explicit 'init' command
149
274
  if (shouldInitOnly) {
150
275
  if (!existsSync(issuesDir)) {
151
276
  mkdirSync(issuesDir, { recursive: true })
152
277
  }
153
278
 
154
- // Only seed with welcome issue if --seed flag is passed
155
279
  if (shouldSeed) {
156
280
  const hasIssues =
157
281
  existsSync(issuesDir) &&
158
282
  readdirSync(issuesDir).some(f => f.endsWith('.md'))
159
283
  if (!hasIssues) {
284
+ // First issue gets the initial order key
285
+ let firstOrderKey = 'a0'
286
+ try {
287
+ const fi = await import('fractional-indexing')
288
+ firstOrderKey = fi.generateKeyBetween(null, null)
289
+ } catch { /* use fallback */ }
290
+
160
291
  const welcome =
161
292
  `---\n` +
162
293
  `title: Welcome to issy\n` +
@@ -164,6 +295,7 @@ if (shouldInitOnly) {
164
295
  `priority: medium\n` +
165
296
  `type: improvement\n` +
166
297
  `status: open\n` +
298
+ `order: ${firstOrderKey}\n` +
167
299
  `created: ${new Date().toISOString().slice(0, 19)}\n` +
168
300
  `---\n\n` +
169
301
  `## Details\n\n` +
@@ -173,7 +305,7 @@ if (shouldInitOnly) {
173
305
  }
174
306
  }
175
307
 
176
- console.log(`Initialized ${issuesDir}`)
308
+ console.log(`Initialized ${issyDir}`)
177
309
  process.exit(0)
178
310
  }
179
311