@os-eco/seeds-cli 0.2.0
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/README.md +226 -0
- package/package.json +42 -0
- package/src/commands/blocked.ts +31 -0
- package/src/commands/close.ts +80 -0
- package/src/commands/create.test.ts +507 -0
- package/src/commands/create.ts +103 -0
- package/src/commands/dep.test.ts +215 -0
- package/src/commands/dep.ts +108 -0
- package/src/commands/doctor.test.ts +524 -0
- package/src/commands/doctor.ts +732 -0
- package/src/commands/init.test.ts +86 -0
- package/src/commands/init.ts +49 -0
- package/src/commands/list.ts +69 -0
- package/src/commands/migrate.ts +117 -0
- package/src/commands/onboard.test.ts +155 -0
- package/src/commands/onboard.ts +140 -0
- package/src/commands/prime.test.ts +94 -0
- package/src/commands/prime.ts +146 -0
- package/src/commands/ready.ts +31 -0
- package/src/commands/show.ts +20 -0
- package/src/commands/stats.ts +58 -0
- package/src/commands/sync.ts +76 -0
- package/src/commands/tpl.test.ts +330 -0
- package/src/commands/tpl.ts +279 -0
- package/src/commands/update.ts +97 -0
- package/src/config.ts +39 -0
- package/src/id.test.ts +55 -0
- package/src/id.ts +22 -0
- package/src/index.ts +122 -0
- package/src/markers.test.ts +73 -0
- package/src/markers.ts +19 -0
- package/src/output.ts +63 -0
- package/src/store.test.ts +173 -0
- package/src/store.ts +143 -0
- package/src/types.ts +61 -0
- package/src/yaml.test.ts +79 -0
- package/src/yaml.ts +27 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
let tmpDir: string;
|
|
7
|
+
|
|
8
|
+
const CLI = join(import.meta.dir, "../../src/index.ts");
|
|
9
|
+
|
|
10
|
+
async function run(
|
|
11
|
+
args: string[],
|
|
12
|
+
cwd: string,
|
|
13
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
14
|
+
const proc = Bun.spawn(["bun", "run", CLI, ...args], {
|
|
15
|
+
cwd,
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
});
|
|
19
|
+
const stdout = await new Response(proc.stdout).text();
|
|
20
|
+
const stderr = await new Response(proc.stderr).text();
|
|
21
|
+
const exitCode = await proc.exited;
|
|
22
|
+
return { stdout, stderr, exitCode };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function runJson<T = unknown>(args: string[], cwd: string): Promise<T> {
|
|
26
|
+
const { stdout } = await run([...args, "--json"], cwd);
|
|
27
|
+
return JSON.parse(stdout) as T;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
tmpDir = await mkdtemp(join(tmpdir(), "seeds-create-test-"));
|
|
32
|
+
await run(["init"], tmpDir);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("sd create", () => {
|
|
40
|
+
test("creates an issue with required title", async () => {
|
|
41
|
+
const result = await runJson<{ success: boolean; id: string }>(
|
|
42
|
+
["create", "--title", "My first issue"],
|
|
43
|
+
tmpDir,
|
|
44
|
+
);
|
|
45
|
+
expect(result.success).toBe(true);
|
|
46
|
+
expect(result.id).toMatch(/^[a-z]+-[0-9a-f]{4}$/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("requires --title flag", async () => {
|
|
50
|
+
const { exitCode } = await run(["create"], tmpDir);
|
|
51
|
+
expect(exitCode).not.toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("defaults to type=task", async () => {
|
|
55
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
56
|
+
["create", "--title", "Task issue"],
|
|
57
|
+
tmpDir,
|
|
58
|
+
);
|
|
59
|
+
const show = await runJson<{ success: boolean; issue: { type: string } }>(
|
|
60
|
+
["show", create.id],
|
|
61
|
+
tmpDir,
|
|
62
|
+
);
|
|
63
|
+
expect(show.issue.type).toBe("task");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("defaults to priority=2 (medium)", async () => {
|
|
67
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
68
|
+
["create", "--title", "Medium priority issue"],
|
|
69
|
+
tmpDir,
|
|
70
|
+
);
|
|
71
|
+
const show = await runJson<{ success: boolean; issue: { priority: number } }>(
|
|
72
|
+
["show", create.id],
|
|
73
|
+
tmpDir,
|
|
74
|
+
);
|
|
75
|
+
expect(show.issue.priority).toBe(2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("accepts --type flag", async () => {
|
|
79
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
80
|
+
["create", "--title", "Bug issue", "--type", "bug"],
|
|
81
|
+
tmpDir,
|
|
82
|
+
);
|
|
83
|
+
const show = await runJson<{ success: boolean; issue: { type: string } }>(
|
|
84
|
+
["show", create.id],
|
|
85
|
+
tmpDir,
|
|
86
|
+
);
|
|
87
|
+
expect(show.issue.type).toBe("bug");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("accepts --type feature", async () => {
|
|
91
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
92
|
+
["create", "--title", "Feature issue", "--type", "feature"],
|
|
93
|
+
tmpDir,
|
|
94
|
+
);
|
|
95
|
+
const show = await runJson<{ success: boolean; issue: { type: string } }>(
|
|
96
|
+
["show", create.id],
|
|
97
|
+
tmpDir,
|
|
98
|
+
);
|
|
99
|
+
expect(show.issue.type).toBe("feature");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("accepts --type epic", async () => {
|
|
103
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
104
|
+
["create", "--title", "Epic issue", "--type", "epic"],
|
|
105
|
+
tmpDir,
|
|
106
|
+
);
|
|
107
|
+
const show = await runJson<{ success: boolean; issue: { type: string } }>(
|
|
108
|
+
["show", create.id],
|
|
109
|
+
tmpDir,
|
|
110
|
+
);
|
|
111
|
+
expect(show.issue.type).toBe("epic");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("accepts --priority as number", async () => {
|
|
115
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
116
|
+
["create", "--title", "High priority", "--priority", "1"],
|
|
117
|
+
tmpDir,
|
|
118
|
+
);
|
|
119
|
+
const show = await runJson<{ success: boolean; issue: { priority: number } }>(
|
|
120
|
+
["show", create.id],
|
|
121
|
+
tmpDir,
|
|
122
|
+
);
|
|
123
|
+
expect(show.issue.priority).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("accepts --priority as P-notation (P1 = 1)", async () => {
|
|
127
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
128
|
+
["create", "--title", "P1 issue", "--priority", "P1"],
|
|
129
|
+
tmpDir,
|
|
130
|
+
);
|
|
131
|
+
const show = await runJson<{ success: boolean; issue: { priority: number } }>(
|
|
132
|
+
["show", create.id],
|
|
133
|
+
tmpDir,
|
|
134
|
+
);
|
|
135
|
+
expect(show.issue.priority).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("accepts --description", async () => {
|
|
139
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
140
|
+
["create", "--title", "With description", "--description", "Some details"],
|
|
141
|
+
tmpDir,
|
|
142
|
+
);
|
|
143
|
+
const show = await runJson<{ success: boolean; issue: { description?: string } }>(
|
|
144
|
+
["show", create.id],
|
|
145
|
+
tmpDir,
|
|
146
|
+
);
|
|
147
|
+
expect(show.issue.description).toBe("Some details");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("accepts --assignee", async () => {
|
|
151
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
152
|
+
["create", "--title", "Assigned issue", "--assignee", "builder-1"],
|
|
153
|
+
tmpDir,
|
|
154
|
+
);
|
|
155
|
+
const show = await runJson<{ success: boolean; issue: { assignee?: string } }>(
|
|
156
|
+
["show", create.id],
|
|
157
|
+
tmpDir,
|
|
158
|
+
);
|
|
159
|
+
expect(show.issue.assignee).toBe("builder-1");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("new issue has status=open", async () => {
|
|
163
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
164
|
+
["create", "--title", "Open issue"],
|
|
165
|
+
tmpDir,
|
|
166
|
+
);
|
|
167
|
+
const show = await runJson<{ success: boolean; issue: { status: string } }>(
|
|
168
|
+
["show", create.id],
|
|
169
|
+
tmpDir,
|
|
170
|
+
);
|
|
171
|
+
expect(show.issue.status).toBe("open");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("new issue has createdAt and updatedAt timestamps", async () => {
|
|
175
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
176
|
+
["create", "--title", "Timestamped issue"],
|
|
177
|
+
tmpDir,
|
|
178
|
+
);
|
|
179
|
+
const show = await runJson<{
|
|
180
|
+
success: boolean;
|
|
181
|
+
issue: { createdAt: string; updatedAt: string };
|
|
182
|
+
}>(["show", create.id], tmpDir);
|
|
183
|
+
expect(show.issue.createdAt).toBeTruthy();
|
|
184
|
+
expect(show.issue.updatedAt).toBeTruthy();
|
|
185
|
+
// Should be valid ISO 8601
|
|
186
|
+
expect(new Date(show.issue.createdAt).toISOString()).toBe(show.issue.createdAt);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("sd show", () => {
|
|
191
|
+
test("returns issue by id", async () => {
|
|
192
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
193
|
+
["create", "--title", "Show test"],
|
|
194
|
+
tmpDir,
|
|
195
|
+
);
|
|
196
|
+
const show = await runJson<{ success: boolean; issue: { id: string; title: string } }>(
|
|
197
|
+
["show", create.id],
|
|
198
|
+
tmpDir,
|
|
199
|
+
);
|
|
200
|
+
expect(show.success).toBe(true);
|
|
201
|
+
expect(show.issue.id).toBe(create.id);
|
|
202
|
+
expect(show.issue.title).toBe("Show test");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("fails for unknown id", async () => {
|
|
206
|
+
const result = await runJson<{ success: boolean; error: string }>(
|
|
207
|
+
["show", "proj-ffff"],
|
|
208
|
+
tmpDir,
|
|
209
|
+
);
|
|
210
|
+
expect(result.success).toBe(false);
|
|
211
|
+
expect(result.error).toBeTruthy();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("sd list", () => {
|
|
216
|
+
test("lists all issues by default", async () => {
|
|
217
|
+
await run(["create", "--title", "Issue 1"], tmpDir);
|
|
218
|
+
await run(["create", "--title", "Issue 2"], tmpDir);
|
|
219
|
+
const result = await runJson<{ success: boolean; issues: unknown[]; count: number }>(
|
|
220
|
+
["list"],
|
|
221
|
+
tmpDir,
|
|
222
|
+
);
|
|
223
|
+
expect(result.success).toBe(true);
|
|
224
|
+
expect(result.count).toBe(2);
|
|
225
|
+
expect(result.issues).toHaveLength(2);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("filters by --status", async () => {
|
|
229
|
+
const c1 = await runJson<{ success: boolean; id: string }>(
|
|
230
|
+
["create", "--title", "Open issue"],
|
|
231
|
+
tmpDir,
|
|
232
|
+
);
|
|
233
|
+
const c2 = await runJson<{ success: boolean; id: string }>(
|
|
234
|
+
["create", "--title", "In progress issue"],
|
|
235
|
+
tmpDir,
|
|
236
|
+
);
|
|
237
|
+
await run(["update", c2.id, "--status", "in_progress"], tmpDir);
|
|
238
|
+
|
|
239
|
+
const result = await runJson<{
|
|
240
|
+
success: boolean;
|
|
241
|
+
issues: Array<{ id: string }>;
|
|
242
|
+
count: number;
|
|
243
|
+
}>(["list", "--status", "open"], tmpDir);
|
|
244
|
+
expect(result.count).toBe(1);
|
|
245
|
+
expect(result.issues[0]?.id).toBe(c1.id);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("filters by --type", async () => {
|
|
249
|
+
await run(["create", "--title", "Task 1", "--type", "task"], tmpDir);
|
|
250
|
+
await run(["create", "--title", "Bug 1", "--type", "bug"], tmpDir);
|
|
251
|
+
|
|
252
|
+
const result = await runJson<{ success: boolean; issues: unknown[]; count: number }>(
|
|
253
|
+
["list", "--type", "bug"],
|
|
254
|
+
tmpDir,
|
|
255
|
+
);
|
|
256
|
+
expect(result.count).toBe(1);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("filters by --assignee", async () => {
|
|
260
|
+
await run(["create", "--title", "Assigned", "--assignee", "alice"], tmpDir);
|
|
261
|
+
await run(["create", "--title", "Unassigned"], tmpDir);
|
|
262
|
+
|
|
263
|
+
const result = await runJson<{ success: boolean; issues: unknown[]; count: number }>(
|
|
264
|
+
["list", "--assignee", "alice"],
|
|
265
|
+
tmpDir,
|
|
266
|
+
);
|
|
267
|
+
expect(result.count).toBe(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("respects --limit", async () => {
|
|
271
|
+
for (let i = 0; i < 5; i++) {
|
|
272
|
+
await run(["create", "--title", `Issue ${i}`], tmpDir);
|
|
273
|
+
}
|
|
274
|
+
const result = await runJson<{ success: boolean; issues: unknown[]; count: number }>(
|
|
275
|
+
["list", "--limit", "3"],
|
|
276
|
+
tmpDir,
|
|
277
|
+
);
|
|
278
|
+
expect(result.issues).toHaveLength(3);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("sd update", () => {
|
|
283
|
+
test("updates status to in_progress", async () => {
|
|
284
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
285
|
+
["create", "--title", "Issue to update"],
|
|
286
|
+
tmpDir,
|
|
287
|
+
);
|
|
288
|
+
await run(["update", create.id, "--status", "in_progress"], tmpDir);
|
|
289
|
+
const show = await runJson<{ success: boolean; issue: { status: string } }>(
|
|
290
|
+
["show", create.id],
|
|
291
|
+
tmpDir,
|
|
292
|
+
);
|
|
293
|
+
expect(show.issue.status).toBe("in_progress");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("updates title", async () => {
|
|
297
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
298
|
+
["create", "--title", "Old title"],
|
|
299
|
+
tmpDir,
|
|
300
|
+
);
|
|
301
|
+
await run(["update", create.id, "--title", "New title"], tmpDir);
|
|
302
|
+
const show = await runJson<{ success: boolean; issue: { title: string } }>(
|
|
303
|
+
["show", create.id],
|
|
304
|
+
tmpDir,
|
|
305
|
+
);
|
|
306
|
+
expect(show.issue.title).toBe("New title");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("updates priority", async () => {
|
|
310
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
311
|
+
["create", "--title", "Issue"],
|
|
312
|
+
tmpDir,
|
|
313
|
+
);
|
|
314
|
+
await run(["update", create.id, "--priority", "0"], tmpDir);
|
|
315
|
+
const show = await runJson<{ success: boolean; issue: { priority: number } }>(
|
|
316
|
+
["show", create.id],
|
|
317
|
+
tmpDir,
|
|
318
|
+
);
|
|
319
|
+
expect(show.issue.priority).toBe(0);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("updates updatedAt timestamp", async () => {
|
|
323
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
324
|
+
["create", "--title", "Issue"],
|
|
325
|
+
tmpDir,
|
|
326
|
+
);
|
|
327
|
+
const before = await runJson<{ success: boolean; issue: { updatedAt: string } }>(
|
|
328
|
+
["show", create.id],
|
|
329
|
+
tmpDir,
|
|
330
|
+
);
|
|
331
|
+
// Small delay to ensure timestamp differs
|
|
332
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
333
|
+
await run(["update", create.id, "--title", "Updated"], tmpDir);
|
|
334
|
+
const after = await runJson<{ success: boolean; issue: { updatedAt: string } }>(
|
|
335
|
+
["show", create.id],
|
|
336
|
+
tmpDir,
|
|
337
|
+
);
|
|
338
|
+
expect(after.issue.updatedAt >= before.issue.updatedAt).toBe(true);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("fails for unknown id", async () => {
|
|
342
|
+
const { exitCode } = await run(["update", "proj-ffff", "--title", "Nope"], tmpDir);
|
|
343
|
+
expect(exitCode).not.toBe(0);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("sd close", () => {
|
|
348
|
+
test("closes an issue", async () => {
|
|
349
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
350
|
+
["create", "--title", "Issue to close"],
|
|
351
|
+
tmpDir,
|
|
352
|
+
);
|
|
353
|
+
await run(["close", create.id], tmpDir);
|
|
354
|
+
const show = await runJson<{ success: boolean; issue: { status: string } }>(
|
|
355
|
+
["show", create.id],
|
|
356
|
+
tmpDir,
|
|
357
|
+
);
|
|
358
|
+
expect(show.issue.status).toBe("closed");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("sets closedAt timestamp", async () => {
|
|
362
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
363
|
+
["create", "--title", "Issue to close"],
|
|
364
|
+
tmpDir,
|
|
365
|
+
);
|
|
366
|
+
await run(["close", create.id], tmpDir);
|
|
367
|
+
const show = await runJson<{ success: boolean; issue: { closedAt?: string } }>(
|
|
368
|
+
["show", create.id],
|
|
369
|
+
tmpDir,
|
|
370
|
+
);
|
|
371
|
+
expect(show.issue.closedAt).toBeTruthy();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("accepts --reason", async () => {
|
|
375
|
+
const create = await runJson<{ success: boolean; id: string }>(
|
|
376
|
+
["create", "--title", "Done issue"],
|
|
377
|
+
tmpDir,
|
|
378
|
+
);
|
|
379
|
+
await run(["close", create.id, "--reason", "Completed in PR #42"], tmpDir);
|
|
380
|
+
const show = await runJson<{ success: boolean; issue: { closeReason?: string } }>(
|
|
381
|
+
["show", create.id],
|
|
382
|
+
tmpDir,
|
|
383
|
+
);
|
|
384
|
+
expect(show.issue.closeReason).toBe("Completed in PR #42");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("closes multiple issues at once", async () => {
|
|
388
|
+
const c1 = await runJson<{ success: boolean; id: string }>(
|
|
389
|
+
["create", "--title", "Issue 1"],
|
|
390
|
+
tmpDir,
|
|
391
|
+
);
|
|
392
|
+
const c2 = await runJson<{ success: boolean; id: string }>(
|
|
393
|
+
["create", "--title", "Issue 2"],
|
|
394
|
+
tmpDir,
|
|
395
|
+
);
|
|
396
|
+
await run(["close", c1.id, c2.id], tmpDir);
|
|
397
|
+
|
|
398
|
+
const s1 = await runJson<{ success: boolean; issue: { status: string } }>(
|
|
399
|
+
["show", c1.id],
|
|
400
|
+
tmpDir,
|
|
401
|
+
);
|
|
402
|
+
const s2 = await runJson<{ success: boolean; issue: { status: string } }>(
|
|
403
|
+
["show", c2.id],
|
|
404
|
+
tmpDir,
|
|
405
|
+
);
|
|
406
|
+
expect(s1.issue.status).toBe("closed");
|
|
407
|
+
expect(s2.issue.status).toBe("closed");
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe("sd ready", () => {
|
|
412
|
+
test("returns open issues with no blockers", async () => {
|
|
413
|
+
await run(["create", "--title", "Ready issue"], tmpDir);
|
|
414
|
+
const result = await runJson<{ success: boolean; issues: unknown[] }>(["ready"], tmpDir);
|
|
415
|
+
expect(result.success).toBe(true);
|
|
416
|
+
expect(result.issues.length).toBeGreaterThan(0);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("excludes blocked issues", async () => {
|
|
420
|
+
const c1 = await runJson<{ success: boolean; id: string }>(
|
|
421
|
+
["create", "--title", "Blocker"],
|
|
422
|
+
tmpDir,
|
|
423
|
+
);
|
|
424
|
+
const c2 = await runJson<{ success: boolean; id: string }>(
|
|
425
|
+
["create", "--title", "Blocked"],
|
|
426
|
+
tmpDir,
|
|
427
|
+
);
|
|
428
|
+
await run(["dep", "add", c2.id, c1.id], tmpDir);
|
|
429
|
+
|
|
430
|
+
const result = await runJson<{ success: boolean; issues: Array<{ id: string }> }>(
|
|
431
|
+
["ready"],
|
|
432
|
+
tmpDir,
|
|
433
|
+
);
|
|
434
|
+
const ids = result.issues.map((i) => i.id);
|
|
435
|
+
expect(ids).not.toContain(c2.id);
|
|
436
|
+
expect(ids).toContain(c1.id);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("excludes closed and in_progress issues", async () => {
|
|
440
|
+
const c1 = await runJson<{ success: boolean; id: string }>(
|
|
441
|
+
["create", "--title", "Closed"],
|
|
442
|
+
tmpDir,
|
|
443
|
+
);
|
|
444
|
+
const c2 = await runJson<{ success: boolean; id: string }>(
|
|
445
|
+
["create", "--title", "In progress"],
|
|
446
|
+
tmpDir,
|
|
447
|
+
);
|
|
448
|
+
await run(["close", c1.id], tmpDir);
|
|
449
|
+
await run(["update", c2.id, "--status", "in_progress"], tmpDir);
|
|
450
|
+
await run(["create", "--title", "Open"], tmpDir);
|
|
451
|
+
|
|
452
|
+
const result = await runJson<{ success: boolean; issues: Array<{ id: string }> }>(
|
|
453
|
+
["ready"],
|
|
454
|
+
tmpDir,
|
|
455
|
+
);
|
|
456
|
+
const ids = result.issues.map((i) => i.id);
|
|
457
|
+
expect(ids).not.toContain(c1.id);
|
|
458
|
+
expect(ids).not.toContain(c2.id);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
describe("sd blocked", () => {
|
|
463
|
+
test("shows blocked issues", async () => {
|
|
464
|
+
const c1 = await runJson<{ success: boolean; id: string }>(
|
|
465
|
+
["create", "--title", "Blocker"],
|
|
466
|
+
tmpDir,
|
|
467
|
+
);
|
|
468
|
+
const c2 = await runJson<{ success: boolean; id: string }>(
|
|
469
|
+
["create", "--title", "Blocked"],
|
|
470
|
+
tmpDir,
|
|
471
|
+
);
|
|
472
|
+
await run(["dep", "add", c2.id, c1.id], tmpDir);
|
|
473
|
+
|
|
474
|
+
const result = await runJson<{ success: boolean; issues: Array<{ id: string }> }>(
|
|
475
|
+
["blocked"],
|
|
476
|
+
tmpDir,
|
|
477
|
+
);
|
|
478
|
+
const ids = result.issues.map((i) => i.id);
|
|
479
|
+
expect(ids).toContain(c2.id);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("returns empty list when no blocked issues", async () => {
|
|
483
|
+
await run(["create", "--title", "Free issue"], tmpDir);
|
|
484
|
+
const result = await runJson<{ success: boolean; issues: unknown[] }>(["blocked"], tmpDir);
|
|
485
|
+
expect(result.issues).toHaveLength(0);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe("sd stats", () => {
|
|
490
|
+
test("returns project statistics", async () => {
|
|
491
|
+
await run(["create", "--title", "Open 1"], tmpDir);
|
|
492
|
+
const c2 = await runJson<{ success: boolean; id: string }>(
|
|
493
|
+
["create", "--title", "Closed 1"],
|
|
494
|
+
tmpDir,
|
|
495
|
+
);
|
|
496
|
+
await run(["close", c2.id], tmpDir);
|
|
497
|
+
|
|
498
|
+
const result = await runJson<{
|
|
499
|
+
success: boolean;
|
|
500
|
+
stats: { open: number; closed: number; total: number };
|
|
501
|
+
}>(["stats"], tmpDir);
|
|
502
|
+
expect(result.success).toBe(true);
|
|
503
|
+
expect(result.stats.open).toBe(1);
|
|
504
|
+
expect(result.stats.closed).toBe(1);
|
|
505
|
+
expect(result.stats.total).toBe(2);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { findSeedsDir, readConfig } from "../config.ts";
|
|
2
|
+
import { generateId } from "../id.ts";
|
|
3
|
+
import { outputJson, printSuccess } from "../output.ts";
|
|
4
|
+
import { appendIssue, issuesPath, readIssues, withLock } from "../store.ts";
|
|
5
|
+
import type { Issue } from "../types.ts";
|
|
6
|
+
import { VALID_TYPES } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
function parseArgs(args: string[]) {
|
|
9
|
+
const flags: Record<string, string | boolean> = {};
|
|
10
|
+
let i = 0;
|
|
11
|
+
while (i < args.length) {
|
|
12
|
+
const arg = args[i];
|
|
13
|
+
if (!arg) {
|
|
14
|
+
i++;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (arg.startsWith("--")) {
|
|
18
|
+
const key = arg.slice(2);
|
|
19
|
+
const eqIdx = key.indexOf("=");
|
|
20
|
+
if (eqIdx !== -1) {
|
|
21
|
+
flags[key.slice(0, eqIdx)] = key.slice(eqIdx + 1);
|
|
22
|
+
i++;
|
|
23
|
+
} else {
|
|
24
|
+
const next = args[i + 1];
|
|
25
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
26
|
+
flags[key] = next;
|
|
27
|
+
i += 2;
|
|
28
|
+
} else {
|
|
29
|
+
flags[key] = true;
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return flags;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parsePriority(val: string | boolean | undefined, defaultVal = 2): number {
|
|
41
|
+
if (val === undefined || val === true) return defaultVal;
|
|
42
|
+
const s = String(val);
|
|
43
|
+
if (s.toUpperCase().startsWith("P")) return Number.parseInt(s.slice(1), 10);
|
|
44
|
+
return Number.parseInt(s, 10);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function run(args: string[], seedsDir?: string): Promise<void> {
|
|
48
|
+
const jsonMode = args.includes("--json");
|
|
49
|
+
const flags = parseArgs(args);
|
|
50
|
+
const title = flags.title;
|
|
51
|
+
if (!title || typeof title !== "string" || !title.trim()) {
|
|
52
|
+
throw new Error("--title is required");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const typeVal = flags.type ?? "task";
|
|
56
|
+
if (typeof typeVal !== "string" || !(VALID_TYPES as readonly string[]).includes(typeVal)) {
|
|
57
|
+
throw new Error(`--type must be one of: ${VALID_TYPES.join(", ")}`);
|
|
58
|
+
}
|
|
59
|
+
const issueType = typeVal as Issue["type"];
|
|
60
|
+
|
|
61
|
+
const priority = parsePriority(flags.priority);
|
|
62
|
+
if (Number.isNaN(priority) || priority < 0 || priority > 4) {
|
|
63
|
+
throw new Error("--priority must be 0-4 or P0-P4");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const assignee = typeof flags.assignee === "string" ? flags.assignee : undefined;
|
|
67
|
+
const description =
|
|
68
|
+
typeof flags.description === "string"
|
|
69
|
+
? flags.description
|
|
70
|
+
: typeof flags.desc === "string"
|
|
71
|
+
? flags.desc
|
|
72
|
+
: undefined;
|
|
73
|
+
|
|
74
|
+
const dir = seedsDir ?? (await findSeedsDir());
|
|
75
|
+
const config = await readConfig(dir);
|
|
76
|
+
|
|
77
|
+
let createdId: string;
|
|
78
|
+
await withLock(issuesPath(dir), async () => {
|
|
79
|
+
const existing = await readIssues(dir);
|
|
80
|
+
const existingIds = new Set(existing.map((i) => i.id));
|
|
81
|
+
const id = generateId(config.project, existingIds);
|
|
82
|
+
const now = new Date().toISOString();
|
|
83
|
+
const issue: Issue = {
|
|
84
|
+
id,
|
|
85
|
+
title: title.trim(),
|
|
86
|
+
status: "open",
|
|
87
|
+
type: issueType,
|
|
88
|
+
priority,
|
|
89
|
+
createdAt: now,
|
|
90
|
+
updatedAt: now,
|
|
91
|
+
...(assignee ? { assignee } : {}),
|
|
92
|
+
...(description ? { description } : {}),
|
|
93
|
+
};
|
|
94
|
+
await appendIssue(dir, issue);
|
|
95
|
+
createdId = id;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (jsonMode) {
|
|
99
|
+
outputJson({ success: true, command: "create", id: createdId! });
|
|
100
|
+
} else {
|
|
101
|
+
printSuccess(`Created ${createdId!}`);
|
|
102
|
+
}
|
|
103
|
+
}
|