issy 0.4.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,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
 
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.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "issy",
3
- "version": "0.4.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",
@@ -35,8 +35,8 @@
35
35
  "lint": "biome check src bin"
36
36
  },
37
37
  "dependencies": {
38
- "@miketromba/issy-app": "^0.4.0",
39
- "@miketromba/issy-core": "^0.4.0",
38
+ "@miketromba/issy-app": "workspace:^",
39
+ "@miketromba/issy-core": "workspace:^",
40
40
  "update-notifier": "^7.3.1"
41
41
  }
42
42
  }
@@ -1,54 +0,0 @@
1
- // src/install-info.ts
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { join } from "node:path";
5
- var CONFIG_DIR = join(homedir(), ".config", "issy");
6
- var INFO_FILE = join(CONFIG_DIR, "install-info.json");
7
- function saveInstallInfo(info) {
8
- try {
9
- if (!existsSync(CONFIG_DIR)) {
10
- mkdirSync(CONFIG_DIR, { recursive: true });
11
- }
12
- writeFileSync(INFO_FILE, JSON.stringify(info, null, 2));
13
- } catch {}
14
- }
15
- function loadInstallInfo() {
16
- try {
17
- if (!existsSync(INFO_FILE)) {
18
- return null;
19
- }
20
- const data = readFileSync(INFO_FILE, "utf-8");
21
- return JSON.parse(data);
22
- } catch {
23
- return null;
24
- }
25
- }
26
- function detectPackageManagerFromEnv() {
27
- const userAgent = process.env.npm_config_user_agent || "";
28
- if (userAgent.includes("bun/"))
29
- return "bun";
30
- if (userAgent.includes("pnpm/"))
31
- return "pnpm";
32
- if (userAgent.includes("yarn/"))
33
- return "yarn";
34
- if (userAgent.includes("npm/"))
35
- return "npm";
36
- return null;
37
- }
38
- function detectIsGlobalFromEnv() {
39
- if (process.env.npm_config_global === "true")
40
- return true;
41
- if (process.env.PNPM_HOME && process.env.npm_config_global !== "false")
42
- return true;
43
- const npmConfigPrefix = process.env.npm_config_prefix || "";
44
- if (npmConfigPrefix.includes("/usr/local") || npmConfigPrefix.includes("/.bun/") || npmConfigPrefix.includes("/.nvm/") || npmConfigPrefix.includes("/pnpm/global")) {
45
- return true;
46
- }
47
- return false;
48
- }
49
- export {
50
- saveInstallInfo,
51
- loadInstallInfo,
52
- detectPackageManagerFromEnv,
53
- detectIsGlobalFromEnv
54
- };
@@ -1,61 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/install-info.ts
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { homedir } from "node:os";
6
- import { join } from "node:path";
7
- var CONFIG_DIR = join(homedir(), ".config", "issy");
8
- var INFO_FILE = join(CONFIG_DIR, "install-info.json");
9
- function saveInstallInfo(info) {
10
- try {
11
- if (!existsSync(CONFIG_DIR)) {
12
- mkdirSync(CONFIG_DIR, { recursive: true });
13
- }
14
- writeFileSync(INFO_FILE, JSON.stringify(info, null, 2));
15
- } catch {}
16
- }
17
- function loadInstallInfo() {
18
- try {
19
- if (!existsSync(INFO_FILE)) {
20
- return null;
21
- }
22
- const data = readFileSync(INFO_FILE, "utf-8");
23
- return JSON.parse(data);
24
- } catch {
25
- return null;
26
- }
27
- }
28
- function detectPackageManagerFromEnv() {
29
- const userAgent = process.env.npm_config_user_agent || "";
30
- if (userAgent.includes("bun/"))
31
- return "bun";
32
- if (userAgent.includes("pnpm/"))
33
- return "pnpm";
34
- if (userAgent.includes("yarn/"))
35
- return "yarn";
36
- if (userAgent.includes("npm/"))
37
- return "npm";
38
- return null;
39
- }
40
- function detectIsGlobalFromEnv() {
41
- if (process.env.npm_config_global === "true")
42
- return true;
43
- if (process.env.PNPM_HOME && process.env.npm_config_global !== "false")
44
- return true;
45
- const npmConfigPrefix = process.env.npm_config_prefix || "";
46
- if (npmConfigPrefix.includes("/usr/local") || npmConfigPrefix.includes("/.bun/") || npmConfigPrefix.includes("/.nvm/") || npmConfigPrefix.includes("/pnpm/global")) {
47
- return true;
48
- }
49
- return false;
50
- }
51
-
52
- // src/postinstall.ts
53
- var pm = detectPackageManagerFromEnv();
54
- var isGlobal = detectIsGlobalFromEnv();
55
- if (pm) {
56
- saveInstallInfo({
57
- packageManager: pm,
58
- isGlobal,
59
- installedAt: new Date().toISOString()
60
- });
61
- }