issy 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/bin/issy CHANGED
@@ -4,12 +4,19 @@ 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'
12
14
  import updateNotifier from 'update-notifier'
15
+ import {
16
+ detectPackageManager,
17
+ isGlobalInstall,
18
+ getUpdateCommand
19
+ } from '../dist/update-checker.js'
13
20
 
14
21
  const args = process.argv.slice(2)
15
22
 
@@ -17,7 +24,21 @@ const args = process.argv.slice(2)
17
24
  const here = resolve(fileURLToPath(import.meta.url), '..')
18
25
  const pkgPath = resolve(here, '..', 'package.json')
19
26
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
20
- updateNotifier({ pkg, updateCheckInterval: 1000 * 60 * 60 }).notify() // 1 hour
27
+
28
+ const notifier = updateNotifier({ pkg, updateCheckInterval: 1000 * 60 })
29
+
30
+ if (notifier.update) {
31
+ const scriptPath = resolve(fileURLToPath(import.meta.url))
32
+ const pm = detectPackageManager(scriptPath)
33
+ const isGlobal = isGlobalInstall(scriptPath)
34
+ const updateCmd = getUpdateCommand(pm, isGlobal)
35
+
36
+ notifier.notify({
37
+ message: `Update available: ${notifier.update.current} → ${notifier.update.latest}\nRun: ${updateCmd}`,
38
+ defer: true,
39
+ boxenOptions: { padding: 1, margin: 1, borderStyle: 'round', borderColor: 'yellow' }
40
+ })
41
+ }
21
42
 
22
43
  const cliCommands = new Set([
23
44
  'list',
@@ -26,6 +47,8 @@ const cliCommands = new Set([
26
47
  'create',
27
48
  'update',
28
49
  'close',
50
+ 'reopen',
51
+ 'next',
29
52
  'help',
30
53
  '--help',
31
54
  '-h'
@@ -38,10 +61,26 @@ if (args[0] === '--version' || args[0] === '-v') {
38
61
  }
39
62
 
40
63
  /**
41
- * Find .issues directory by walking up from the given path.
42
- * Returns the path if found, or null if not found.
64
+ * Find .issy directory by walking up from the given path.
43
65
  */
44
- 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) {
45
84
  let current = resolve(fromPath)
46
85
  for (let i = 0; i < 20; i++) {
47
86
  const candidate = join(current, '.issues')
@@ -57,7 +96,6 @@ function findIssuesDirUpward(fromPath) {
57
96
 
58
97
  /**
59
98
  * Find the git repository root by walking up from the given path.
60
- * Returns the directory containing .git, or null if not in a git repo.
61
99
  */
62
100
  function findGitRoot(fromPath) {
63
101
  let current = resolve(fromPath)
@@ -74,47 +112,154 @@ function findGitRoot(fromPath) {
74
112
  }
75
113
 
76
114
  /**
77
- * Resolve the issues directory using priority:
78
- * 1. ISSUES_DIR env var (explicit override)
79
- * 2. Walk up from ISSUES_ROOT or cwd to find existing .issues
80
- * 3. If in a git repo, use .issues at the repo root
81
- * 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
82
120
  */
83
- function resolveIssuesDir() {
84
- // 1. Explicit override
85
- if (process.env.ISSUES_DIR) {
86
- return resolve(process.env.ISSUES_DIR)
121
+ function resolveIssyDir() {
122
+ if (process.env.ISSY_DIR) {
123
+ return resolve(process.env.ISSY_DIR)
87
124
  }
88
125
 
89
- // 2. Try to find existing .issues by walking up
90
- const startDir = process.env.ISSUES_ROOT || process.cwd()
91
- const found = findIssuesDirUpward(startDir)
126
+ const startDir = process.env.ISSY_ROOT || process.cwd()
127
+ const found = findIssyDirUpward(startDir)
92
128
  if (found) {
93
129
  return found
94
130
  }
95
131
 
96
- // 3. If in a git repo, use .issues at the repo root
97
132
  const gitRoot = findGitRoot(startDir)
98
133
  if (gitRoot) {
99
- return join(gitRoot, '.issues')
134
+ return join(gitRoot, '.issy')
100
135
  }
101
136
 
102
- // 4. Fall back to creating in start directory
103
- return join(resolve(startDir), '.issues')
137
+ return join(resolve(startDir), '.issy')
104
138
  }
105
139
 
106
- const issuesDir = resolveIssuesDir()
140
+ const issyDir = resolveIssyDir()
141
+ const issuesDir = join(issyDir, 'issues')
142
+
107
143
  // Set env vars for downstream consumers
108
- process.env.ISSUES_DIR = issuesDir
109
- // Set ISSUES_ROOT to the parent of .issues for consistency
110
- 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
+ }
111
256
 
112
257
  if (cliCommands.has(args[0] || '')) {
113
258
  const here = resolve(fileURLToPath(import.meta.url), '..')
114
259
  const entry = resolve(here, '..', 'dist', 'cli.js')
115
260
  process.argv = [process.argv[0], process.argv[1], ...args]
116
261
  const cli = await import(entry)
117
- await cli.ready // Wait for main() to complete before exiting
262
+ await cli.ready
118
263
  process.exit(0)
119
264
  }
120
265
 
@@ -126,18 +271,23 @@ if (portIdx >= 0 && args[portIdx + 1]) {
126
271
  const shouldInitOnly = args.includes('init')
127
272
  const shouldSeed = args.includes('--seed')
128
273
 
129
- // Only create .issues directory on explicit 'init' command
130
274
  if (shouldInitOnly) {
131
275
  if (!existsSync(issuesDir)) {
132
276
  mkdirSync(issuesDir, { recursive: true })
133
277
  }
134
278
 
135
- // Only seed with welcome issue if --seed flag is passed
136
279
  if (shouldSeed) {
137
280
  const hasIssues =
138
281
  existsSync(issuesDir) &&
139
282
  readdirSync(issuesDir).some(f => f.endsWith('.md'))
140
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
+
141
291
  const welcome =
142
292
  `---\n` +
143
293
  `title: Welcome to issy\n` +
@@ -145,6 +295,7 @@ if (shouldInitOnly) {
145
295
  `priority: medium\n` +
146
296
  `type: improvement\n` +
147
297
  `status: open\n` +
298
+ `order: ${firstOrderKey}\n` +
148
299
  `created: ${new Date().toISOString().slice(0, 19)}\n` +
149
300
  `---\n\n` +
150
301
  `## Details\n\n` +
@@ -154,7 +305,7 @@ if (shouldInitOnly) {
154
305
  }
155
306
  }
156
307
 
157
- console.log(`Initialized ${issuesDir}`)
308
+ console.log(`Initialized ${issyDir}`)
158
309
  process.exit(0)
159
310
  }
160
311
 
package/dist/cli.js CHANGED
@@ -7,23 +7,209 @@ import { parseArgs } from "util";
7
7
  import { existsSync } from "node:fs";
8
8
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
9
9
  import { dirname, join, resolve } from "node:path";
10
+
11
+ // ../../node_modules/.bun/fractional-indexing@3.2.0/node_modules/fractional-indexing/src/index.js
12
+ var BASE_62_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
13
+ function midpoint(a, b, digits) {
14
+ const zero = digits[0];
15
+ if (b != null && a >= b) {
16
+ throw new Error(a + " >= " + b);
17
+ }
18
+ if (a.slice(-1) === zero || b && b.slice(-1) === zero) {
19
+ throw new Error("trailing zero");
20
+ }
21
+ if (b) {
22
+ let n = 0;
23
+ while ((a[n] || zero) === b[n]) {
24
+ n++;
25
+ }
26
+ if (n > 0) {
27
+ return b.slice(0, n) + midpoint(a.slice(n), b.slice(n), digits);
28
+ }
29
+ }
30
+ const digitA = a ? digits.indexOf(a[0]) : 0;
31
+ const digitB = b != null ? digits.indexOf(b[0]) : digits.length;
32
+ if (digitB - digitA > 1) {
33
+ const midDigit = Math.round(0.5 * (digitA + digitB));
34
+ return digits[midDigit];
35
+ } else {
36
+ if (b && b.length > 1) {
37
+ return b.slice(0, 1);
38
+ } else {
39
+ return digits[digitA] + midpoint(a.slice(1), null, digits);
40
+ }
41
+ }
42
+ }
43
+ function validateInteger(int) {
44
+ if (int.length !== getIntegerLength(int[0])) {
45
+ throw new Error("invalid integer part of order key: " + int);
46
+ }
47
+ }
48
+ function getIntegerLength(head) {
49
+ if (head >= "a" && head <= "z") {
50
+ return head.charCodeAt(0) - 97 + 2;
51
+ } else if (head >= "A" && head <= "Z") {
52
+ return 90 - head.charCodeAt(0) + 2;
53
+ } else {
54
+ throw new Error("invalid order key head: " + head);
55
+ }
56
+ }
57
+ function getIntegerPart(key) {
58
+ const integerPartLength = getIntegerLength(key[0]);
59
+ if (integerPartLength > key.length) {
60
+ throw new Error("invalid order key: " + key);
61
+ }
62
+ return key.slice(0, integerPartLength);
63
+ }
64
+ function validateOrderKey(key, digits) {
65
+ if (key === "A" + digits[0].repeat(26)) {
66
+ throw new Error("invalid order key: " + key);
67
+ }
68
+ const i = getIntegerPart(key);
69
+ const f = key.slice(i.length);
70
+ if (f.slice(-1) === digits[0]) {
71
+ throw new Error("invalid order key: " + key);
72
+ }
73
+ }
74
+ function incrementInteger(x, digits) {
75
+ validateInteger(x);
76
+ const [head, ...digs] = x.split("");
77
+ let carry = true;
78
+ for (let i = digs.length - 1;carry && i >= 0; i--) {
79
+ const d = digits.indexOf(digs[i]) + 1;
80
+ if (d === digits.length) {
81
+ digs[i] = digits[0];
82
+ } else {
83
+ digs[i] = digits[d];
84
+ carry = false;
85
+ }
86
+ }
87
+ if (carry) {
88
+ if (head === "Z") {
89
+ return "a" + digits[0];
90
+ }
91
+ if (head === "z") {
92
+ return null;
93
+ }
94
+ const h = String.fromCharCode(head.charCodeAt(0) + 1);
95
+ if (h > "a") {
96
+ digs.push(digits[0]);
97
+ } else {
98
+ digs.pop();
99
+ }
100
+ return h + digs.join("");
101
+ } else {
102
+ return head + digs.join("");
103
+ }
104
+ }
105
+ function decrementInteger(x, digits) {
106
+ validateInteger(x);
107
+ const [head, ...digs] = x.split("");
108
+ let borrow = true;
109
+ for (let i = digs.length - 1;borrow && i >= 0; i--) {
110
+ const d = digits.indexOf(digs[i]) - 1;
111
+ if (d === -1) {
112
+ digs[i] = digits.slice(-1);
113
+ } else {
114
+ digs[i] = digits[d];
115
+ borrow = false;
116
+ }
117
+ }
118
+ if (borrow) {
119
+ if (head === "a") {
120
+ return "Z" + digits.slice(-1);
121
+ }
122
+ if (head === "A") {
123
+ return null;
124
+ }
125
+ const h = String.fromCharCode(head.charCodeAt(0) - 1);
126
+ if (h < "Z") {
127
+ digs.push(digits.slice(-1));
128
+ } else {
129
+ digs.pop();
130
+ }
131
+ return h + digs.join("");
132
+ } else {
133
+ return head + digs.join("");
134
+ }
135
+ }
136
+ function generateKeyBetween(a, b, digits = BASE_62_DIGITS) {
137
+ if (a != null) {
138
+ validateOrderKey(a, digits);
139
+ }
140
+ if (b != null) {
141
+ validateOrderKey(b, digits);
142
+ }
143
+ if (a != null && b != null && a >= b) {
144
+ throw new Error(a + " >= " + b);
145
+ }
146
+ if (a == null) {
147
+ if (b == null) {
148
+ return "a" + digits[0];
149
+ }
150
+ const ib2 = getIntegerPart(b);
151
+ const fb2 = b.slice(ib2.length);
152
+ if (ib2 === "A" + digits[0].repeat(26)) {
153
+ return ib2 + midpoint("", fb2, digits);
154
+ }
155
+ if (ib2 < b) {
156
+ return ib2;
157
+ }
158
+ const res = decrementInteger(ib2, digits);
159
+ if (res == null) {
160
+ throw new Error("cannot decrement any more");
161
+ }
162
+ return res;
163
+ }
164
+ if (b == null) {
165
+ const ia2 = getIntegerPart(a);
166
+ const fa2 = a.slice(ia2.length);
167
+ const i2 = incrementInteger(ia2, digits);
168
+ return i2 == null ? ia2 + midpoint(fa2, null, digits) : i2;
169
+ }
170
+ const ia = getIntegerPart(a);
171
+ const fa = a.slice(ia.length);
172
+ const ib = getIntegerPart(b);
173
+ const fb = b.slice(ib.length);
174
+ if (ia === ib) {
175
+ return ia + midpoint(fa, fb, digits);
176
+ }
177
+ const i = incrementInteger(ia, digits);
178
+ if (i == null) {
179
+ throw new Error("cannot increment any more");
180
+ }
181
+ if (i < b) {
182
+ return i;
183
+ }
184
+ return ia + midpoint(fa, null, digits);
185
+ }
186
+
187
+ // ../core/src/lib/issues.ts
188
+ var issyDir = null;
10
189
  var issuesDir = null;
11
- function setIssuesDir(dir) {
12
- issuesDir = dir;
190
+ function setIssyDir(dir) {
191
+ issyDir = dir;
192
+ issuesDir = join(dir, "issues");
193
+ }
194
+ function getIssyDir() {
195
+ if (!issyDir) {
196
+ throw new Error("Issy directory not initialized. Call resolveIssyDir() first.");
197
+ }
198
+ return issyDir;
13
199
  }
14
200
  function getIssuesDir() {
15
201
  if (!issuesDir) {
16
- throw new Error("Issues directory not initialized. Call setIssuesDir() or resolveIssuesDir() first.");
202
+ throw new Error("Issues directory not initialized. Call resolveIssyDir() first.");
17
203
  }
18
204
  return issuesDir;
19
205
  }
20
206
  async function ensureIssuesDir() {
21
207
  await mkdir(getIssuesDir(), { recursive: true });
22
208
  }
23
- function findIssuesDirUpward(fromPath) {
209
+ function findIssyDirUpward(fromPath) {
24
210
  let current = resolve(fromPath);
25
211
  for (let i = 0;i < 20; i++) {
26
- const candidate = join(current, ".issues");
212
+ const candidate = join(current, ".issy");
27
213
  if (existsSync(candidate)) {
28
214
  return candidate;
29
215
  }
@@ -48,26 +234,26 @@ function findGitRoot(fromPath) {
48
234
  }
49
235
  return null;
50
236
  }
51
- function resolveIssuesDir() {
52
- if (process.env.ISSUES_DIR) {
53
- const dir = resolve(process.env.ISSUES_DIR);
54
- setIssuesDir(dir);
237
+ function resolveIssyDir() {
238
+ if (process.env.ISSY_DIR) {
239
+ const dir = resolve(process.env.ISSY_DIR);
240
+ setIssyDir(dir);
55
241
  return dir;
56
242
  }
57
- const startDir = process.env.ISSUES_ROOT || process.cwd();
58
- const found = findIssuesDirUpward(startDir);
243
+ const startDir = process.env.ISSY_ROOT || process.cwd();
244
+ const found = findIssyDirUpward(startDir);
59
245
  if (found) {
60
- setIssuesDir(found);
246
+ setIssyDir(found);
61
247
  return found;
62
248
  }
63
249
  const gitRoot = findGitRoot(startDir);
64
250
  if (gitRoot) {
65
- const gitIssuesDir = join(gitRoot, ".issues");
66
- setIssuesDir(gitIssuesDir);
67
- return gitIssuesDir;
251
+ const gitIssyDir = join(gitRoot, ".issy");
252
+ setIssyDir(gitIssyDir);
253
+ return gitIssyDir;
68
254
  }
69
- const fallback = join(resolve(startDir), ".issues");
70
- setIssuesDir(fallback);
255
+ const fallback = join(resolve(startDir), ".issy");
256
+ setIssyDir(fallback);
71
257
  return fallback;
72
258
  }
73
259
  function parseFrontmatter(content) {
@@ -101,6 +287,9 @@ function generateFrontmatter(data) {
101
287
  lines.push(`labels: ${data.labels}`);
102
288
  }
103
289
  lines.push(`status: ${data.status}`);
290
+ if (data.order) {
291
+ lines.push(`order: ${data.order}`);
292
+ }
104
293
  lines.push(`created: ${data.created}`);
105
294
  if (data.updated) {
106
295
  lines.push(`updated: ${data.updated}`);
@@ -165,16 +354,60 @@ async function getAllIssues() {
165
354
  content: body
166
355
  });
167
356
  }
168
- const priorityOrder = { high: 0, medium: 1, low: 2 };
169
357
  return issues.sort((a, b) => {
170
- const priorityA = priorityOrder[a.frontmatter.priority] ?? 999;
171
- const priorityB = priorityOrder[b.frontmatter.priority] ?? 999;
172
- if (priorityA !== priorityB) {
173
- return priorityA - priorityB;
174
- }
175
- return b.id.localeCompare(a.id);
358
+ const orderA = a.frontmatter.order;
359
+ const orderB = b.frontmatter.order;
360
+ if (orderA && orderB)
361
+ return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
362
+ if (orderA && !orderB)
363
+ return -1;
364
+ if (!orderA && orderB)
365
+ return 1;
366
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
176
367
  });
177
368
  }
369
+ async function getOpenIssuesByOrder() {
370
+ const allIssues = await getAllIssues();
371
+ return allIssues.filter((i) => i.frontmatter.status === "open");
372
+ }
373
+ function computeOrderKey(openIssues, options, excludeId) {
374
+ const issues = excludeId ? openIssues.filter((i) => i.id !== excludeId.padStart(4, "0")) : openIssues;
375
+ if (options.first) {
376
+ if (issues.length === 0)
377
+ return generateKeyBetween(null, null);
378
+ const firstOrder = issues[0].frontmatter.order || null;
379
+ return generateKeyBetween(null, firstOrder);
380
+ }
381
+ if (options.last) {
382
+ if (issues.length === 0)
383
+ return generateKeyBetween(null, null);
384
+ const lastOrder2 = issues[issues.length - 1].frontmatter.order || null;
385
+ return generateKeyBetween(lastOrder2, null);
386
+ }
387
+ if (options.after) {
388
+ const targetId = options.after.padStart(4, "0");
389
+ const idx = issues.findIndex((i) => i.id === targetId);
390
+ if (idx === -1)
391
+ throw new Error(`Issue #${options.after} not found among open issues. The --after target must be an open issue.`);
392
+ const afterOrder = issues[idx].frontmatter.order || null;
393
+ const nextOrder = idx + 1 < issues.length ? issues[idx + 1].frontmatter.order || null : null;
394
+ return generateKeyBetween(afterOrder, nextOrder);
395
+ }
396
+ if (options.before) {
397
+ const targetId = options.before.padStart(4, "0");
398
+ const idx = issues.findIndex((i) => i.id === targetId);
399
+ if (idx === -1)
400
+ throw new Error(`Issue #${options.before} not found among open issues. The --before target must be an open issue.`);
401
+ const beforeOrder = issues[idx].frontmatter.order || null;
402
+ const prevOrder = idx > 0 ? issues[idx - 1].frontmatter.order || null : null;
403
+ return generateKeyBetween(prevOrder, beforeOrder);
404
+ }
405
+ if (issues.length === 0) {
406
+ return generateKeyBetween(null, null);
407
+ }
408
+ const lastOrder = issues[issues.length - 1].frontmatter.order || null;
409
+ return generateKeyBetween(lastOrder, null);
410
+ }
178
411
  async function createIssue(input) {
179
412
  await ensureIssuesDir();
180
413
  if (!input.title) {
@@ -203,6 +436,7 @@ async function createIssue(input) {
203
436
  type,
204
437
  labels: input.labels || undefined,
205
438
  status: "open",
439
+ order: input.order || undefined,
206
440
  created: formatDate()
207
441
  };
208
442
  const content = `${generateFrontmatter(frontmatter)}
@@ -241,6 +475,7 @@ async function updateIssue(id, input) {
241
475
  labels: input.labels || undefined
242
476
  },
243
477
  ...input.status && { status: input.status },
478
+ ...input.order && { order: input.order },
244
479
  updated: formatDate()
245
480
  };
246
481
  const content = `${generateFrontmatter(updatedFrontmatter)}
@@ -254,6 +489,21 @@ ${issue.content}`;
254
489
  async function closeIssue(id) {
255
490
  return updateIssue(id, { status: "closed" });
256
491
  }
492
+ async function reopenIssue(id, order) {
493
+ return updateIssue(id, { status: "open", order });
494
+ }
495
+ async function getOnCloseContent() {
496
+ try {
497
+ const onClosePath = join(getIssyDir(), "on_close.md");
498
+ return await readFile(onClosePath, "utf-8");
499
+ } catch {
500
+ return null;
501
+ }
502
+ }
503
+ async function getNextIssue() {
504
+ const openIssues = await getOpenIssuesByOrder();
505
+ return openIssues.length > 0 ? openIssues[0] : null;
506
+ }
257
507
  // ../core/src/lib/query-parser.ts
258
508
  var SUPPORTED_QUALIFIERS = new Set([
259
509
  "is",
@@ -263,6 +513,59 @@ var SUPPORTED_QUALIFIERS = new Set([
263
513
  "label",
264
514
  "sort"
265
515
  ]);
516
+ function parseQuery(query) {
517
+ const qualifiers = {};
518
+ const searchTextParts = [];
519
+ if (!query || !query.trim()) {
520
+ return { qualifiers, searchText: "" };
521
+ }
522
+ const tokens = tokenizeQuery(query);
523
+ for (const token of tokens) {
524
+ const colonIndex = token.indexOf(":");
525
+ if (colonIndex > 0 && colonIndex < token.length - 1) {
526
+ const key = token.substring(0, colonIndex);
527
+ const value = token.substring(colonIndex + 1);
528
+ if (SUPPORTED_QUALIFIERS.has(key)) {
529
+ qualifiers[key] = value;
530
+ } else {
531
+ searchTextParts.push(token);
532
+ }
533
+ } else {
534
+ searchTextParts.push(token);
535
+ }
536
+ }
537
+ return {
538
+ qualifiers,
539
+ searchText: searchTextParts.join(" ").trim()
540
+ };
541
+ }
542
+ function tokenizeQuery(query) {
543
+ const tokens = [];
544
+ let currentToken = "";
545
+ let inQuotes = false;
546
+ let quoteChar = "";
547
+ for (let i = 0;i < query.length; i++) {
548
+ const char = query[i];
549
+ if ((char === '"' || char === "'") && !inQuotes) {
550
+ inQuotes = true;
551
+ quoteChar = char;
552
+ } else if (char === quoteChar && inQuotes) {
553
+ inQuotes = false;
554
+ quoteChar = "";
555
+ } else if (char === " " && !inQuotes) {
556
+ if (currentToken) {
557
+ tokens.push(currentToken);
558
+ currentToken = "";
559
+ }
560
+ } else {
561
+ currentToken += char;
562
+ }
563
+ }
564
+ if (currentToken) {
565
+ tokens.push(currentToken);
566
+ }
567
+ return tokens;
568
+ }
266
569
  // ../../node_modules/.bun/fuse.js@7.1.0/node_modules/fuse.js/dist/fuse.mjs
267
570
  function isArray(value) {
268
571
  return !Array.isArray ? getTag(value) === "[object Array]" : Array.isArray(value);
@@ -1099,7 +1402,7 @@ var searchers = [
1099
1402
  var searchersLen = searchers.length;
1100
1403
  var SPACE_RE = / +(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/;
1101
1404
  var OR_TOKEN = "|";
1102
- function parseQuery(pattern, options = {}) {
1405
+ function parseQuery2(pattern, options = {}) {
1103
1406
  return pattern.split(OR_TOKEN).map((item) => {
1104
1407
  let query = item.trim().split(SPACE_RE).filter((item2) => item2 && !!item2.trim());
1105
1408
  let results = [];
@@ -1160,7 +1463,7 @@ class ExtendedSearch {
1160
1463
  pattern = isCaseSensitive ? pattern : pattern.toLowerCase();
1161
1464
  pattern = ignoreDiacritics ? stripDiacritics(pattern) : pattern;
1162
1465
  this.pattern = pattern;
1163
- this.query = parseQuery(this.pattern, this.options);
1466
+ this.query = parseQuery2(this.pattern, this.options);
1164
1467
  }
1165
1468
  static condition(_, options) {
1166
1469
  return options.useExtendedSearch;
@@ -1572,38 +1875,147 @@ var FUSE_OPTIONS = {
1572
1875
  function createSearchIndex(issues) {
1573
1876
  return new Fuse(issues, FUSE_OPTIONS);
1574
1877
  }
1575
- function filterIssues(issues, filters) {
1576
- return issues.filter((issue) => {
1577
- if (filters.status && issue.frontmatter.status !== filters.status) {
1578
- return false;
1878
+ function sortIssues(issues, sortBy) {
1879
+ const sortOption = sortBy.toLowerCase();
1880
+ if (sortOption === "roadmap") {
1881
+ issues.sort((a, b) => {
1882
+ const orderA = a.frontmatter.order;
1883
+ const orderB = b.frontmatter.order;
1884
+ if (orderA && orderB)
1885
+ return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
1886
+ if (orderA && !orderB)
1887
+ return -1;
1888
+ if (!orderA && orderB)
1889
+ return 1;
1890
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
1891
+ });
1892
+ } else if (sortOption === "priority") {
1893
+ const priorityOrder = {
1894
+ high: 0,
1895
+ medium: 1,
1896
+ low: 2
1897
+ };
1898
+ issues.sort((a, b) => {
1899
+ const priorityA = priorityOrder[a.frontmatter.priority] ?? 999;
1900
+ const priorityB = priorityOrder[b.frontmatter.priority] ?? 999;
1901
+ if (priorityA !== priorityB)
1902
+ return priorityA - priorityB;
1903
+ return b.id.localeCompare(a.id);
1904
+ });
1905
+ } else if (sortOption === "scope") {
1906
+ const scopeOrder = {
1907
+ small: 0,
1908
+ medium: 1,
1909
+ large: 2
1910
+ };
1911
+ issues.sort((a, b) => {
1912
+ const scopeA = a.frontmatter.scope ? scopeOrder[a.frontmatter.scope] ?? 99 : 99;
1913
+ const scopeB = b.frontmatter.scope ? scopeOrder[b.frontmatter.scope] ?? 99 : 99;
1914
+ if (scopeA !== scopeB)
1915
+ return scopeA - scopeB;
1916
+ return b.id.localeCompare(a.id);
1917
+ });
1918
+ } else if (sortOption === "created") {
1919
+ issues.sort((a, b) => {
1920
+ const dateA = a.frontmatter.created || "";
1921
+ const dateB = b.frontmatter.created || "";
1922
+ if (dateA !== dateB)
1923
+ return dateB.localeCompare(dateA);
1924
+ return b.id.localeCompare(a.id);
1925
+ });
1926
+ } else if (sortOption === "created-asc") {
1927
+ issues.sort((a, b) => {
1928
+ const dateA = a.frontmatter.created || "";
1929
+ const dateB = b.frontmatter.created || "";
1930
+ if (dateA !== dateB)
1931
+ return dateA.localeCompare(dateB);
1932
+ return a.id.localeCompare(b.id);
1933
+ });
1934
+ } else if (sortOption === "updated") {
1935
+ issues.sort((a, b) => {
1936
+ const dateA = a.frontmatter.updated || a.frontmatter.created || "";
1937
+ const dateB = b.frontmatter.updated || b.frontmatter.created || "";
1938
+ if (dateA !== dateB)
1939
+ return dateB.localeCompare(dateA);
1940
+ return b.id.localeCompare(a.id);
1941
+ });
1942
+ } else if (sortOption === "id") {
1943
+ issues.sort((a, b) => b.id.localeCompare(a.id));
1944
+ } else {
1945
+ issues.sort((a, b) => {
1946
+ const orderA = a.frontmatter.order;
1947
+ const orderB = b.frontmatter.order;
1948
+ if (orderA && orderB)
1949
+ return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
1950
+ if (orderA && !orderB)
1951
+ return -1;
1952
+ if (!orderA && orderB)
1953
+ return 1;
1954
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
1955
+ });
1956
+ }
1957
+ }
1958
+ function filterByQuery(issues, query) {
1959
+ const parsed = parseQuery(query);
1960
+ let result = issues.filter((issue) => {
1961
+ if (parsed.qualifiers.is) {
1962
+ const statusValue = parsed.qualifiers.is.toLowerCase();
1963
+ if (statusValue === "open" || statusValue === "closed") {
1964
+ if (issue.frontmatter.status !== statusValue) {
1965
+ return false;
1966
+ }
1967
+ }
1579
1968
  }
1580
- if (filters.priority && issue.frontmatter.priority !== filters.priority) {
1581
- return false;
1969
+ if (parsed.qualifiers.priority) {
1970
+ const priorityValue = parsed.qualifiers.priority.toLowerCase();
1971
+ if (priorityValue === "high" || priorityValue === "medium" || priorityValue === "low") {
1972
+ if (issue.frontmatter.priority !== priorityValue) {
1973
+ return false;
1974
+ }
1975
+ }
1976
+ }
1977
+ if (parsed.qualifiers.scope) {
1978
+ const scopeValue = parsed.qualifiers.scope.toLowerCase();
1979
+ if (scopeValue === "small" || scopeValue === "medium" || scopeValue === "large") {
1980
+ if (issue.frontmatter.scope !== scopeValue) {
1981
+ return false;
1982
+ }
1983
+ }
1582
1984
  }
1583
- if (filters.scope && issue.frontmatter.scope !== filters.scope) {
1584
- return false;
1985
+ if (parsed.qualifiers.type) {
1986
+ const typeValue = parsed.qualifiers.type.toLowerCase();
1987
+ if (typeValue === "bug" || typeValue === "improvement") {
1988
+ if (issue.frontmatter.type !== typeValue) {
1989
+ return false;
1990
+ }
1991
+ }
1585
1992
  }
1586
- if (filters.type && issue.frontmatter.type !== filters.type) {
1587
- return false;
1993
+ if (parsed.qualifiers.label) {
1994
+ const labelQuery = parsed.qualifiers.label.toLowerCase();
1995
+ const issueLabels = (issue.frontmatter.labels || "").toLowerCase();
1996
+ if (!issueLabels.includes(labelQuery)) {
1997
+ return false;
1998
+ }
1588
1999
  }
1589
2000
  return true;
1590
2001
  });
1591
- }
1592
- function filterAndSearchIssues(issues, filters) {
1593
- let result = filterIssues(issues, filters);
1594
- if (filters.search?.trim()) {
1595
- const query = filters.search.trim();
2002
+ if (!parsed.searchText.trim()) {
2003
+ const sortBy = parsed.qualifiers.sort?.toLowerCase() || "roadmap";
2004
+ sortIssues(result, sortBy);
2005
+ }
2006
+ if (parsed.searchText.trim()) {
2007
+ const searchQuery = parsed.searchText.trim();
1596
2008
  const idMatches = [];
1597
2009
  const nonIdMatches = [];
1598
- const normalizedQuery = query.replace(/^0+/, "");
2010
+ const normalizedQuery = searchQuery.replace(/^0+/, "");
1599
2011
  for (const issue of result) {
1600
2012
  const normalizedId = issue.id.replace(/^0+/, "");
1601
- if (normalizedId.startsWith(normalizedQuery) || issue.id.startsWith(query)) {
2013
+ if (normalizedId.startsWith(normalizedQuery) || issue.id.startsWith(searchQuery)) {
1602
2014
  idMatches.push(issue);
1603
2015
  }
1604
2016
  }
1605
- const fuse = createSearchIndex(issues);
1606
- const searchResults = fuse.search(query);
2017
+ const fuse = createSearchIndex(result);
2018
+ const searchResults = fuse.search(searchQuery);
1607
2019
  const matchedIds = new Set(searchResults.map((r) => r.item.id));
1608
2020
  const idMatchSet = new Set(idMatches.map((i) => i.id));
1609
2021
  for (const issue of result) {
@@ -1621,7 +2033,7 @@ function filterAndSearchIssues(issues, filters) {
1621
2033
  return result;
1622
2034
  }
1623
2035
  // src/cli.ts
1624
- resolveIssuesDir();
2036
+ resolveIssyDir();
1625
2037
  function prioritySymbol(priority) {
1626
2038
  switch (priority) {
1627
2039
  case "high":
@@ -1637,15 +2049,46 @@ function prioritySymbol(priority) {
1637
2049
  function typeSymbol(type) {
1638
2050
  return type === "bug" ? "\uD83D\uDC1B" : "\u2728";
1639
2051
  }
2052
+ function formatIssueRow(issue) {
2053
+ const status = issue.frontmatter.status === "open" ? "OPEN " : "CLOSED";
2054
+ return ` ${issue.id} ${prioritySymbol(issue.frontmatter.priority)} ${typeSymbol(issue.frontmatter.type)} ${status} ${issue.frontmatter.title.slice(0, 45)}`;
2055
+ }
2056
+ async function resolvePosition(opts) {
2057
+ const openIssues = await getOpenIssuesByOrder();
2058
+ const relevantIssues = opts.excludeId ? openIssues.filter((i) => i.id !== opts.excludeId?.padStart(4, "0")) : openIssues;
2059
+ const positionFlags = [opts.before, opts.after, opts.first, opts.last].filter(Boolean).length;
2060
+ if (positionFlags > 1) {
2061
+ throw new Error("Only one of --before, --after, --first, or --last can be specified.");
2062
+ }
2063
+ const hasPosition = opts.before || opts.after || opts.first || opts.last;
2064
+ if (relevantIssues.length > 0 && opts.requireIfOpenIssues && !hasPosition) {
2065
+ const ids = relevantIssues.map((i) => `#${i.id}`).join(", ");
2066
+ throw new Error(`A position flag (--before, --after, --first, or --last) is required when there are open issues. Open issues: ${ids}`);
2067
+ }
2068
+ return computeOrderKey(openIssues, {
2069
+ before: opts.before,
2070
+ after: opts.after,
2071
+ first: opts.first,
2072
+ last: opts.last
2073
+ }, opts.excludeId);
2074
+ }
1640
2075
  async function listIssues(options) {
1641
2076
  const allIssues = await getAllIssues();
1642
- const issues = filterAndSearchIssues(allIssues, {
1643
- status: options.all ? undefined : "open",
1644
- priority: options.priority,
1645
- scope: options.scope,
1646
- type: options.type,
1647
- search: options.search
1648
- });
2077
+ const queryParts = [];
2078
+ if (!options.all)
2079
+ queryParts.push("is:open");
2080
+ if (options.priority)
2081
+ queryParts.push(`priority:${options.priority}`);
2082
+ if (options.scope)
2083
+ queryParts.push(`scope:${options.scope}`);
2084
+ if (options.type)
2085
+ queryParts.push(`type:${options.type}`);
2086
+ if (options.sort)
2087
+ queryParts.push(`sort:${options.sort}`);
2088
+ if (options.search)
2089
+ queryParts.push(options.search);
2090
+ const query = queryParts.join(" ") || "is:open";
2091
+ const issues = filterByQuery(allIssues, query);
1649
2092
  if (issues.length === 0) {
1650
2093
  console.log("No issues found.");
1651
2094
  return;
@@ -1654,8 +2097,7 @@ async function listIssues(options) {
1654
2097
  ID Pri Type Status Title`);
1655
2098
  console.log(` ${"-".repeat(70)}`);
1656
2099
  for (const issue of issues) {
1657
- const status = issue.frontmatter.status === "open" ? "OPEN " : "CLOSED";
1658
- console.log(` ${issue.id} ${prioritySymbol(issue.frontmatter.priority)} ${typeSymbol(issue.frontmatter.type)} ${status} ${issue.frontmatter.title.slice(0, 45)}`);
2100
+ console.log(formatIssueRow(issue));
1659
2101
  }
1660
2102
  console.log(`
1661
2103
  Total: ${issues.length} issue(s)
@@ -1681,6 +2123,9 @@ ${"=".repeat(70)}`);
1681
2123
  if (issue.frontmatter.labels) {
1682
2124
  console.log(` Labels: ${issue.frontmatter.labels}`);
1683
2125
  }
2126
+ if (issue.frontmatter.order) {
2127
+ console.log(` Order: ${issue.frontmatter.order}`);
2128
+ }
1684
2129
  console.log(` Created: ${issue.frontmatter.created}`);
1685
2130
  if (issue.frontmatter.updated) {
1686
2131
  console.log(` Updated: ${issue.frontmatter.updated}`);
@@ -1691,10 +2136,8 @@ ${"=".repeat(70)}`);
1691
2136
  }
1692
2137
  async function searchIssuesCommand(query, options) {
1693
2138
  const allIssues = await getAllIssues();
1694
- const issues = filterAndSearchIssues(allIssues, {
1695
- status: options.all ? undefined : "open",
1696
- search: query
1697
- });
2139
+ const searchQuery = options.all ? query : `is:open ${query}`;
2140
+ const issues = filterByQuery(allIssues, searchQuery);
1698
2141
  if (issues.length === 0) {
1699
2142
  console.log(`No issues found matching "${query}".`);
1700
2143
  return;
@@ -1705,8 +2148,7 @@ async function searchIssuesCommand(query, options) {
1705
2148
  ID Pri Type Status Title`);
1706
2149
  console.log(` ${"-".repeat(70)}`);
1707
2150
  for (const issue of issues) {
1708
- const status = issue.frontmatter.status === "open" ? "OPEN " : "CLOSED";
1709
- console.log(` ${issue.id} ${prioritySymbol(issue.frontmatter.priority)} ${typeSymbol(issue.frontmatter.type)} ${status} ${issue.frontmatter.title.slice(0, 45)}`);
2151
+ console.log(formatIssueRow(issue));
1710
2152
  }
1711
2153
  console.log(`
1712
2154
  Found: ${issues.length} issue(s)
@@ -1746,13 +2188,21 @@ Create New Issue`);
1746
2188
  process.exit(1);
1747
2189
  }
1748
2190
  try {
2191
+ const order = await resolvePosition({
2192
+ before: options.before,
2193
+ after: options.after,
2194
+ first: options.first,
2195
+ last: options.last,
2196
+ requireIfOpenIssues: true
2197
+ });
1749
2198
  const input = {
1750
2199
  title: options.title,
1751
2200
  description: options.description,
1752
2201
  priority: options.priority,
1753
2202
  scope: options.scope,
1754
2203
  type: options.type,
1755
- labels: options.labels
2204
+ labels: options.labels,
2205
+ order
1756
2206
  };
1757
2207
  const issue = await createIssue(input);
1758
2208
  console.log(`
@@ -1764,6 +2214,17 @@ Created issue: ${issue.filename}`);
1764
2214
  }
1765
2215
  async function updateIssueCommand(id, options) {
1766
2216
  try {
2217
+ let order;
2218
+ if (options.before || options.after || options.first || options.last) {
2219
+ order = await resolvePosition({
2220
+ before: options.before,
2221
+ after: options.after,
2222
+ first: options.first,
2223
+ last: options.last,
2224
+ requireIfOpenIssues: false,
2225
+ excludeId: id
2226
+ });
2227
+ }
1767
2228
  const issue = await updateIssue(id, {
1768
2229
  title: options.title,
1769
2230
  description: options.description,
@@ -1771,7 +2232,7 @@ async function updateIssueCommand(id, options) {
1771
2232
  scope: options.scope,
1772
2233
  type: options.type,
1773
2234
  labels: options.labels,
1774
- status: options.status
2235
+ order
1775
2236
  });
1776
2237
  console.log(`Updated issue: ${issue.filename}`);
1777
2238
  } catch (e) {
@@ -1783,11 +2244,49 @@ async function closeIssueCommand(id) {
1783
2244
  try {
1784
2245
  await closeIssue(id);
1785
2246
  console.log("Issue closed.");
2247
+ const onCloseContent = await getOnCloseContent();
2248
+ if (onCloseContent) {
2249
+ console.log(`
2250
+ ${onCloseContent.trim()}
2251
+ `);
2252
+ }
1786
2253
  } catch (e) {
1787
2254
  console.error(e instanceof Error ? e.message : "Failed to close issue");
1788
2255
  process.exit(1);
1789
2256
  }
1790
2257
  }
2258
+ async function reopenIssueCommand(id, options) {
2259
+ try {
2260
+ const order = await resolvePosition({
2261
+ before: options.before,
2262
+ after: options.after,
2263
+ first: options.first,
2264
+ last: options.last,
2265
+ requireIfOpenIssues: true,
2266
+ excludeId: id
2267
+ });
2268
+ await reopenIssue(id, order);
2269
+ console.log("Issue reopened.");
2270
+ } catch (e) {
2271
+ console.error(e instanceof Error ? e.message : "Failed to reopen issue");
2272
+ process.exit(1);
2273
+ }
2274
+ }
2275
+ async function nextIssueCommand() {
2276
+ const issue = await getNextIssue();
2277
+ if (!issue) {
2278
+ console.log("No open issues.");
2279
+ return;
2280
+ }
2281
+ console.log(`
2282
+ Next issue:`);
2283
+ console.log(` ${"-".repeat(60)}`);
2284
+ console.log(` #${issue.id} ${prioritySymbol(issue.frontmatter.priority)} ${typeSymbol(issue.frontmatter.type)} ${issue.frontmatter.title}`);
2285
+ if (issue.frontmatter.description !== issue.frontmatter.title) {
2286
+ console.log(` ${issue.frontmatter.description}`);
2287
+ }
2288
+ console.log();
2289
+ }
1791
2290
  async function main() {
1792
2291
  const args = process.argv.slice(2);
1793
2292
  const command = args[0];
@@ -1802,25 +2301,32 @@ Options:
1802
2301
  --version, -v Show version number
1803
2302
 
1804
2303
  Commands:
1805
- list List all open issues
2304
+ list List all open issues (roadmap order)
1806
2305
  --all, -a Include closed issues
1807
2306
  --priority, -p <p> Filter by priority (high, medium, low)
1808
2307
  --scope <s> Filter by scope (small, medium, large)
1809
2308
  --type, -t <t> Filter by type (bug, improvement)
1810
2309
  --search, -s <q> Fuzzy search issues
2310
+ --sort <s> Sort: roadmap (default), priority, created, updated, id
1811
2311
 
1812
2312
  search <query> Fuzzy search issues
1813
2313
  --all, -a Include closed issues
1814
2314
 
1815
2315
  read <id> Read a specific issue
1816
2316
 
1817
- create Create a new issue (interactive)
2317
+ next Show the next issue to work on
2318
+
2319
+ create Create a new issue
1818
2320
  --title, -t <t> Issue title
1819
2321
  --description, -d <d> Short description
1820
2322
  --priority, -p <p> Priority (high, medium, low)
1821
2323
  --scope <s> Scope (small, medium, large)
1822
2324
  --type <t> Type (bug, improvement)
1823
2325
  --labels, -l <l> Comma-separated labels
2326
+ --before <id> Insert before this issue in roadmap
2327
+ --after <id> Insert after this issue in roadmap
2328
+ --first Insert at the beginning of the roadmap
2329
+ --last Insert at the end of the roadmap
1824
2330
 
1825
2331
  update <id> Update an issue
1826
2332
  --title, -t <t> New title
@@ -1829,20 +2335,30 @@ Commands:
1829
2335
  --scope <s> New scope
1830
2336
  --type <t> New type
1831
2337
  --labels, -l <l> New labels
1832
- --status, -s <s> New status (open, closed)
2338
+ --before <id> Move before this issue in roadmap
2339
+ --after <id> Move after this issue in roadmap
2340
+ --first Move to the beginning of the roadmap
2341
+ --last Move to the end of the roadmap
1833
2342
 
1834
2343
  close <id> Close an issue
1835
2344
 
2345
+ reopen <id> Reopen a closed issue
2346
+ --before <id> Insert before this issue in roadmap
2347
+ --after <id> Insert after this issue in roadmap
2348
+ --first Insert at the beginning of the roadmap
2349
+ --last Insert at the end of the roadmap
2350
+
1836
2351
  Examples:
1837
2352
  issy list
1838
2353
  issy list --priority high --type bug
1839
- issy list --scope large
1840
- issy search "dashboard"
1841
- issy search "k8s" --all
2354
+ issy next
1842
2355
  issy read 0001
1843
- issy create --title "Fix login bug" --type bug --priority high --scope small
1844
- issy update 0001 --priority low --scope medium
2356
+ issy create --title "Fix login bug" --type bug --priority high --after 0002
2357
+ issy create --title "Add dark mode" --last
2358
+ issy create --title "Urgent fix" --first
2359
+ issy update 0001 --priority low --after 0003
1845
2360
  issy close 0001
2361
+ issy reopen 0001 --last
1846
2362
  `);
1847
2363
  return;
1848
2364
  }
@@ -1855,7 +2371,8 @@ Examples:
1855
2371
  priority: { type: "string", short: "p" },
1856
2372
  scope: { type: "string" },
1857
2373
  type: { type: "string", short: "t" },
1858
- search: { type: "string", short: "s" }
2374
+ search: { type: "string", short: "s" },
2375
+ sort: { type: "string" }
1859
2376
  },
1860
2377
  allowPositionals: true
1861
2378
  });
@@ -1887,6 +2404,10 @@ Examples:
1887
2404
  await readIssue(id);
1888
2405
  break;
1889
2406
  }
2407
+ case "next": {
2408
+ await nextIssueCommand();
2409
+ break;
2410
+ }
1890
2411
  case "create": {
1891
2412
  const { values } = parseArgs({
1892
2413
  args: args.slice(1),
@@ -1896,7 +2417,11 @@ Examples:
1896
2417
  priority: { type: "string", short: "p" },
1897
2418
  scope: { type: "string" },
1898
2419
  type: { type: "string" },
1899
- labels: { type: "string", short: "l" }
2420
+ labels: { type: "string", short: "l" },
2421
+ before: { type: "string" },
2422
+ after: { type: "string" },
2423
+ first: { type: "boolean" },
2424
+ last: { type: "boolean" }
1900
2425
  },
1901
2426
  allowPositionals: true
1902
2427
  });
@@ -1918,7 +2443,10 @@ Examples:
1918
2443
  scope: { type: "string" },
1919
2444
  type: { type: "string" },
1920
2445
  labels: { type: "string", short: "l" },
1921
- status: { type: "string", short: "s" }
2446
+ before: { type: "string" },
2447
+ after: { type: "string" },
2448
+ first: { type: "boolean" },
2449
+ last: { type: "boolean" }
1922
2450
  },
1923
2451
  allowPositionals: true
1924
2452
  });
@@ -1934,6 +2462,25 @@ Examples:
1934
2462
  await closeIssueCommand(id);
1935
2463
  break;
1936
2464
  }
2465
+ case "reopen": {
2466
+ const id = args[1];
2467
+ if (!id) {
2468
+ console.error("Usage: issy reopen <id>");
2469
+ process.exit(1);
2470
+ }
2471
+ const { values } = parseArgs({
2472
+ args: args.slice(2),
2473
+ options: {
2474
+ before: { type: "string" },
2475
+ after: { type: "string" },
2476
+ first: { type: "boolean" },
2477
+ last: { type: "boolean" }
2478
+ },
2479
+ allowPositionals: true
2480
+ });
2481
+ await reopenIssueCommand(id, values);
2482
+ break;
2483
+ }
1937
2484
  default:
1938
2485
  console.error(`Unknown command: ${command}`);
1939
2486
  console.log('Run "issy help" for usage.');
@@ -0,0 +1,99 @@
1
+ // src/update-checker.ts
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ function detectPackageManagerFromPath(scriptPath) {
5
+ if (scriptPath.includes("/.bun/"))
6
+ return "bun";
7
+ if (scriptPath.includes("/pnpm/") || scriptPath.includes("/.pnpm"))
8
+ return "pnpm";
9
+ if (scriptPath.includes("/.yarn/") || scriptPath.includes("/yarn/global"))
10
+ return "yarn";
11
+ if (scriptPath.includes("/.nvm/"))
12
+ return "npm";
13
+ if (scriptPath.includes("/usr/local/lib/node_modules") || scriptPath.includes("/usr/lib/node_modules") || scriptPath.includes("/.npm-global/")) {
14
+ return "npm";
15
+ }
16
+ return null;
17
+ }
18
+ function detectPackageManager(scriptPath, env = process.env, cwd = process.cwd()) {
19
+ if (scriptPath) {
20
+ const fromPath = detectPackageManagerFromPath(scriptPath);
21
+ if (fromPath)
22
+ return fromPath;
23
+ }
24
+ const userAgent = env.npm_config_user_agent || "";
25
+ if (userAgent.includes("bun/"))
26
+ return "bun";
27
+ if (userAgent.includes("pnpm/"))
28
+ return "pnpm";
29
+ if (userAgent.includes("yarn/"))
30
+ return "yarn";
31
+ if (userAgent.includes("npm/"))
32
+ return "npm";
33
+ if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock")))
34
+ return "bun";
35
+ if (existsSync(join(cwd, "pnpm-lock.yaml")))
36
+ return "pnpm";
37
+ if (existsSync(join(cwd, "yarn.lock")))
38
+ return "yarn";
39
+ if (existsSync(join(cwd, "package-lock.json")))
40
+ return "npm";
41
+ return "npm";
42
+ }
43
+ function isGlobalInstall(scriptPath, env = process.env) {
44
+ if (env.npm_config_global === "true")
45
+ return true;
46
+ const globalPaths = [
47
+ "/usr/local/lib/node_modules",
48
+ "/usr/lib/node_modules",
49
+ join(env.HOME || "", ".npm-global"),
50
+ join(env.HOME || "", ".nvm"),
51
+ join(env.HOME || "", ".bun/install/global"),
52
+ join(env.APPDATA || "", "npm"),
53
+ join(env.LOCALAPPDATA || "", "pnpm/global")
54
+ ].filter(Boolean);
55
+ for (const globalPath of globalPaths) {
56
+ if (scriptPath.startsWith(globalPath))
57
+ return true;
58
+ }
59
+ const parts = scriptPath.split("/node_modules/");
60
+ if (parts.length > 1) {
61
+ const projectRoot = parts[0];
62
+ if (existsSync(join(projectRoot, "package.json"))) {
63
+ return false;
64
+ }
65
+ }
66
+ if (env.npm_execpath?.includes("npx") || env._?.includes("npx") || env._?.includes("bunx") || env._?.includes("pnpm dlx")) {
67
+ return true;
68
+ }
69
+ return true;
70
+ }
71
+ function getUpdateCommand(packageManager, isGlobal) {
72
+ const pkgName = "issy";
73
+ const commands = {
74
+ npm: {
75
+ global: `npm install -g ${pkgName}`,
76
+ local: `npm update ${pkgName}`
77
+ },
78
+ yarn: {
79
+ global: `yarn global add ${pkgName}`,
80
+ local: `yarn upgrade ${pkgName}`
81
+ },
82
+ pnpm: {
83
+ global: `pnpm add -g ${pkgName}`,
84
+ local: `pnpm update ${pkgName}`
85
+ },
86
+ bun: {
87
+ global: `bun add -g ${pkgName}`,
88
+ local: `bun update ${pkgName}`
89
+ }
90
+ };
91
+ const pm = commands[packageManager] || commands.npm;
92
+ return isGlobal ? pm.global : pm.local;
93
+ }
94
+ export {
95
+ isGlobalInstall,
96
+ getUpdateCommand,
97
+ detectPackageManagerFromPath,
98
+ detectPackageManager
99
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "issy",
3
- "version": "0.3.0",
3
+ "version": "0.5.1",
4
4
  "description": "AI-native issue tracking. Markdown files in .issues/, managed by your coding assistant.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,14 +28,15 @@
28
28
  "node": ">=18.0.0"
29
29
  },
30
30
  "scripts": {
31
- "build": "bun build src/cli.ts --outdir dist --target node --format esm --external @miketromba/issy-app",
31
+ "build": "bun build src/cli.ts src/update-checker.ts --outdir dist --target node --format esm --external @miketromba/issy-app",
32
32
  "prepublishOnly": "bun run build",
33
33
  "cli": "bun src/cli.ts",
34
+ "test": "bun test",
34
35
  "lint": "biome check src bin"
35
36
  },
36
37
  "dependencies": {
37
- "@miketromba/issy-app": "^0.3.0",
38
- "@miketromba/issy-core": "^0.3.0",
38
+ "@miketromba/issy-app": "workspace:^",
39
+ "@miketromba/issy-core": "workspace:^",
39
40
  "update-notifier": "^7.3.1"
40
41
  }
41
42
  }