trekoon 0.1.9 → 0.2.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/.agents/skills/trekoon/SKILL.md +176 -230
- package/README.md +299 -7
- package/package.json +1 -1
- package/src/commands/arg-parser.ts +198 -0
- package/src/commands/dep.ts +197 -25
- package/src/commands/epic.ts +674 -28
- package/src/commands/error-utils.ts +111 -0
- package/src/commands/events.ts +23 -3
- package/src/commands/help.ts +66 -4
- package/src/commands/init.ts +11 -3
- package/src/commands/migrate.ts +11 -4
- package/src/commands/subtask.ts +408 -26
- package/src/commands/sync.ts +7 -1
- package/src/commands/task.ts +381 -26
- package/src/domain/mutation-service.ts +394 -1
- package/src/domain/tracker-domain.ts +674 -0
- package/src/domain/types.ts +107 -0
- package/src/sync/event-writes.ts +21 -1
- package/src/sync/service.ts +42 -0
package/src/commands/epic.ts
CHANGED
|
@@ -1,20 +1,40 @@
|
|
|
1
1
|
import {
|
|
2
|
+
SEARCH_REPLACE_FIELDS,
|
|
3
|
+
findUnknownOption,
|
|
2
4
|
hasFlag,
|
|
5
|
+
isValidCompactTempKey,
|
|
3
6
|
parseArgs,
|
|
7
|
+
parseCompactEntityRef,
|
|
8
|
+
parseCompactFields,
|
|
9
|
+
parseCsvEnumOption,
|
|
4
10
|
parseStrictNonNegativeInt,
|
|
5
11
|
parseStrictPositiveInt,
|
|
6
12
|
readEnumOption,
|
|
7
13
|
readMissingOptionValue,
|
|
8
14
|
readOption,
|
|
15
|
+
readOptions,
|
|
16
|
+
readUnexpectedPositionals,
|
|
17
|
+
resolvePreviewApplyMode,
|
|
18
|
+
suggestOptions,
|
|
9
19
|
} from "./arg-parser";
|
|
20
|
+
import { unexpectedFailureResult } from "./error-utils";
|
|
10
21
|
|
|
11
22
|
import { MutationService } from "../domain/mutation-service";
|
|
12
23
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
13
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
COMPACT_TEMP_KEY_PREFIX,
|
|
26
|
+
type CompactBatchResultContract,
|
|
27
|
+
type CompactDependencySpec,
|
|
28
|
+
type CompactEntityRef,
|
|
29
|
+
type CompactSubtaskSpec,
|
|
30
|
+
type CompactTaskSpec,
|
|
31
|
+
type EpicRecord,
|
|
32
|
+
type SearchEntityMatch,
|
|
33
|
+
} from "../domain/types";
|
|
14
34
|
import { formatHumanTable } from "../io/human-table";
|
|
15
35
|
import { failResult, okResult } from "../io/output";
|
|
16
36
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
17
|
-
import { openTrekoonDatabase } from "../storage/database";
|
|
37
|
+
import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
|
|
18
38
|
|
|
19
39
|
function formatEpic(epic: EpicRecord): string {
|
|
20
40
|
return `${epic.id} | ${epic.title} | ${epic.status}`;
|
|
@@ -24,6 +44,10 @@ const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
|
|
|
24
44
|
const LIST_VIEW_MODES = ["table", "compact"] as const;
|
|
25
45
|
const DEFAULT_LIST_LIMIT = 10;
|
|
26
46
|
const DEFAULT_OPEN_STATUSES = ["in_progress", "in-progress", "todo"] as const;
|
|
47
|
+
const CREATE_OPTIONS = ["title", "t", "description", "d", "status", "s", "task", "subtask", "dep"] as const;
|
|
48
|
+
const SEARCH_OPTIONS = ["fields", "preview"] as const;
|
|
49
|
+
const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
|
|
50
|
+
const EXPAND_OPTIONS = ["task", "subtask", "dep"] as const;
|
|
27
51
|
|
|
28
52
|
function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
|
|
29
53
|
if (rawStatuses === undefined) {
|
|
@@ -36,6 +60,55 @@ function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
|
|
|
36
60
|
.filter((value) => value.length > 0);
|
|
37
61
|
}
|
|
38
62
|
|
|
63
|
+
function prefixedOptions(options: readonly string[]): string[] {
|
|
64
|
+
return options.map((option) => `--${option}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function unknownOption(command: string, option: string, allowedOptions: readonly string[]): CliResult {
|
|
68
|
+
const suggestions = suggestOptions(option, allowedOptions).map((suggestion) => `--${suggestion}`);
|
|
69
|
+
const suggestionMessage = suggestions.length > 0 ? ` Did you mean ${suggestions.join(" or ")}?` : "";
|
|
70
|
+
return failResult({
|
|
71
|
+
command,
|
|
72
|
+
human: `Unknown option --${option}.${suggestionMessage}`,
|
|
73
|
+
data: {
|
|
74
|
+
option: `--${option}`,
|
|
75
|
+
allowedOptions: prefixedOptions(allowedOptions),
|
|
76
|
+
suggestions,
|
|
77
|
+
},
|
|
78
|
+
error: {
|
|
79
|
+
code: "unknown_option",
|
|
80
|
+
message: `Unknown option --${option}`,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function invalidSearchInput(command: string, human: string, message: string, data: Record<string, unknown>): CliResult {
|
|
86
|
+
return failResult({
|
|
87
|
+
command,
|
|
88
|
+
human,
|
|
89
|
+
data,
|
|
90
|
+
error: {
|
|
91
|
+
code: "invalid_input",
|
|
92
|
+
message,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatSearchHuman(matches: readonly SearchEntityMatch[], emptyMessage: string): string {
|
|
98
|
+
if (matches.length === 0) {
|
|
99
|
+
return emptyMessage;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return matches
|
|
103
|
+
.map(
|
|
104
|
+
(match) =>
|
|
105
|
+
`${match.kind} ${match.id}: ${match.fields
|
|
106
|
+
.map((field) => `${field.field}(${field.count}) "${field.snippet}"`)
|
|
107
|
+
.join(", ")}`,
|
|
108
|
+
)
|
|
109
|
+
.join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
39
112
|
function getStatusPriority(status: string): number {
|
|
40
113
|
if (status === "in_progress" || status === "in-progress") {
|
|
41
114
|
return 0;
|
|
@@ -248,36 +321,346 @@ function formatEpicShowTable(tree: {
|
|
|
248
321
|
}
|
|
249
322
|
|
|
250
323
|
function failFromError(error: unknown, command: string): CliResult {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
code: error.code,
|
|
257
|
-
...(error.details ?? {}),
|
|
258
|
-
},
|
|
259
|
-
error: {
|
|
260
|
-
code: error.code,
|
|
261
|
-
message: error.message,
|
|
262
|
-
},
|
|
263
|
-
});
|
|
264
|
-
}
|
|
324
|
+
return unexpectedFailureResult(error, {
|
|
325
|
+
command,
|
|
326
|
+
human: "Unexpected epic command failure",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
265
329
|
|
|
330
|
+
function failBatchSpec(command: string, human: string, data: Record<string, unknown>): CliResult {
|
|
266
331
|
return failResult({
|
|
267
332
|
command,
|
|
268
|
-
human
|
|
269
|
-
data
|
|
333
|
+
human,
|
|
334
|
+
data,
|
|
270
335
|
error: {
|
|
271
|
-
code: "
|
|
272
|
-
message:
|
|
336
|
+
code: "invalid_input",
|
|
337
|
+
message: human,
|
|
273
338
|
},
|
|
274
339
|
});
|
|
275
340
|
}
|
|
276
341
|
|
|
342
|
+
function failUnexpectedPositionals(command: string, unexpected: readonly string[]): CliResult {
|
|
343
|
+
return failBatchSpec(command, `Unexpected positional arguments: ${unexpected.join(", ")}.`, {
|
|
344
|
+
unexpectedPositionals: unexpected,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function failEmptyCompactField(command: string, option: string, index: number, rawSpec: string, field: string): CliResult {
|
|
349
|
+
const label = option === "task" ? "Task" : "Subtask";
|
|
350
|
+
return failBatchSpec(command, `${label} spec ${index + 1} is missing a ${field}.`, {
|
|
351
|
+
option,
|
|
352
|
+
index,
|
|
353
|
+
rawSpec,
|
|
354
|
+
field,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function validateCompactEntityRef(
|
|
359
|
+
command: string,
|
|
360
|
+
option: string,
|
|
361
|
+
index: number,
|
|
362
|
+
rawSpec: string,
|
|
363
|
+
label: string,
|
|
364
|
+
reference: CompactEntityRef,
|
|
365
|
+
): CliResult | undefined {
|
|
366
|
+
if (reference.kind === "temp_key" && !isValidCompactTempKey(reference.tempKey)) {
|
|
367
|
+
return failBatchSpec(command, `${label} in --${option} spec ${index + 1} must use ${COMPACT_TEMP_KEY_PREFIX}<temp-key> with letters, numbers, dot, dash, or underscore.`, {
|
|
368
|
+
option,
|
|
369
|
+
index,
|
|
370
|
+
rawSpec,
|
|
371
|
+
reference,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (reference.kind === "id" && reference.id.trim().length === 0) {
|
|
376
|
+
return failBatchSpec(command, `${label} in --${option} spec ${index + 1} is required.`, {
|
|
377
|
+
option,
|
|
378
|
+
index,
|
|
379
|
+
rawSpec,
|
|
380
|
+
reference,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function parseExpandTaskSpecs(rawSpecs: readonly string[]): { specs: CompactTaskSpec[]; error?: CliResult } {
|
|
388
|
+
const specs: CompactTaskSpec[] = [];
|
|
389
|
+
const seenTempKeys = new Set<string>();
|
|
390
|
+
|
|
391
|
+
for (const [index, rawSpec] of rawSpecs.entries()) {
|
|
392
|
+
const parsed = parseCompactFields(rawSpec);
|
|
393
|
+
if (parsed.invalidEscape !== null) {
|
|
394
|
+
return {
|
|
395
|
+
specs: [],
|
|
396
|
+
error: failBatchSpec("epic.expand", `Invalid escape sequence ${parsed.invalidEscape} in --task spec ${index + 1}.`, {
|
|
397
|
+
option: "task",
|
|
398
|
+
index,
|
|
399
|
+
rawSpec,
|
|
400
|
+
}),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (parsed.hasDanglingEscape) {
|
|
405
|
+
return {
|
|
406
|
+
specs: [],
|
|
407
|
+
error: failBatchSpec("epic.expand", `Trailing escape in --task spec ${index + 1}.`, {
|
|
408
|
+
option: "task",
|
|
409
|
+
index,
|
|
410
|
+
rawSpec,
|
|
411
|
+
}),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (parsed.fields.length !== 4) {
|
|
416
|
+
return {
|
|
417
|
+
specs: [],
|
|
418
|
+
error: failBatchSpec("epic.expand", `Task specs must use <temp-key>|<title>|<description>|<status> in --task spec ${index + 1}.`, {
|
|
419
|
+
option: "task",
|
|
420
|
+
index,
|
|
421
|
+
rawSpec,
|
|
422
|
+
fields: parsed.fields,
|
|
423
|
+
}),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const tempKey = parsed.fields[0] ?? "";
|
|
428
|
+
const title = parsed.fields[1] ?? "";
|
|
429
|
+
const description = parsed.fields[2] ?? "";
|
|
430
|
+
const status = parsed.fields[3] ?? "";
|
|
431
|
+
if (!tempKey || !isValidCompactTempKey(tempKey)) {
|
|
432
|
+
return {
|
|
433
|
+
specs: [],
|
|
434
|
+
error: failBatchSpec("epic.expand", `Task spec ${index + 1} must start with a temp key like seed-1.`, {
|
|
435
|
+
option: "task",
|
|
436
|
+
index,
|
|
437
|
+
rawSpec,
|
|
438
|
+
tempKey,
|
|
439
|
+
}),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (seenTempKeys.has(tempKey)) {
|
|
444
|
+
return {
|
|
445
|
+
specs: [],
|
|
446
|
+
error: failBatchSpec("epic.expand", `Duplicate temp key '${tempKey}' across --task specs.`, {
|
|
447
|
+
option: "task",
|
|
448
|
+
index,
|
|
449
|
+
rawSpec,
|
|
450
|
+
tempKey,
|
|
451
|
+
}),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!title || title.trim().length === 0) {
|
|
456
|
+
return {
|
|
457
|
+
specs: [],
|
|
458
|
+
error: failBatchSpec("epic.expand", `Task spec ${index + 1} is missing a title.`, {
|
|
459
|
+
option: "task",
|
|
460
|
+
index,
|
|
461
|
+
rawSpec,
|
|
462
|
+
}),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (description.trim().length === 0) {
|
|
467
|
+
return {
|
|
468
|
+
specs: [],
|
|
469
|
+
error: failEmptyCompactField("epic.expand", "task", index, rawSpec, "description"),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
seenTempKeys.add(tempKey);
|
|
474
|
+
const spec: CompactTaskSpec = status.length > 0
|
|
475
|
+
? { tempKey, title, description, status }
|
|
476
|
+
: { tempKey, title, description };
|
|
477
|
+
specs.push(spec);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return { specs };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function parseExpandSubtaskSpecs(rawSpecs: readonly string[]): { specs: CompactSubtaskSpec[]; error?: CliResult } {
|
|
484
|
+
const specs: CompactSubtaskSpec[] = [];
|
|
485
|
+
const seenTempKeys = new Set<string>();
|
|
486
|
+
|
|
487
|
+
for (const [index, rawSpec] of rawSpecs.entries()) {
|
|
488
|
+
const parsed = parseCompactFields(rawSpec);
|
|
489
|
+
if (parsed.invalidEscape !== null) {
|
|
490
|
+
return {
|
|
491
|
+
specs: [],
|
|
492
|
+
error: failBatchSpec("epic.expand", `Invalid escape sequence ${parsed.invalidEscape} in --subtask spec ${index + 1}.`, {
|
|
493
|
+
option: "subtask",
|
|
494
|
+
index,
|
|
495
|
+
rawSpec,
|
|
496
|
+
}),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (parsed.hasDanglingEscape) {
|
|
501
|
+
return {
|
|
502
|
+
specs: [],
|
|
503
|
+
error: failBatchSpec("epic.expand", `Trailing escape in --subtask spec ${index + 1}.`, {
|
|
504
|
+
option: "subtask",
|
|
505
|
+
index,
|
|
506
|
+
rawSpec,
|
|
507
|
+
}),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (parsed.fields.length !== 5) {
|
|
512
|
+
return {
|
|
513
|
+
specs: [],
|
|
514
|
+
error: failBatchSpec("epic.expand", `Subtask specs must use <parent-ref>|<temp-key>|<title>|<description>|<status> in --subtask spec ${index + 1}.`, {
|
|
515
|
+
option: "subtask",
|
|
516
|
+
index,
|
|
517
|
+
rawSpec,
|
|
518
|
+
fields: parsed.fields,
|
|
519
|
+
}),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const parent = parseCompactEntityRef(parsed.fields[0] ?? "");
|
|
524
|
+
const parentError = validateCompactEntityRef("epic.expand", "subtask", index, rawSpec, "Parent ref", parent);
|
|
525
|
+
if (parentError !== undefined) {
|
|
526
|
+
return { specs: [], error: parentError };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const tempKey = parsed.fields[1] ?? "";
|
|
530
|
+
const title = parsed.fields[2] ?? "";
|
|
531
|
+
const description = parsed.fields[3] ?? "";
|
|
532
|
+
const status = parsed.fields[4] ?? "";
|
|
533
|
+
if (!tempKey || !isValidCompactTempKey(tempKey)) {
|
|
534
|
+
return {
|
|
535
|
+
specs: [],
|
|
536
|
+
error: failBatchSpec("epic.expand", `Subtask spec ${index + 1} must include a valid temp key.`, {
|
|
537
|
+
option: "subtask",
|
|
538
|
+
index,
|
|
539
|
+
rawSpec,
|
|
540
|
+
tempKey,
|
|
541
|
+
}),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (seenTempKeys.has(tempKey)) {
|
|
546
|
+
return {
|
|
547
|
+
specs: [],
|
|
548
|
+
error: failBatchSpec("epic.expand", `Duplicate temp key '${tempKey}' across --subtask specs.`, {
|
|
549
|
+
option: "subtask",
|
|
550
|
+
index,
|
|
551
|
+
rawSpec,
|
|
552
|
+
tempKey,
|
|
553
|
+
}),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!title || title.trim().length === 0) {
|
|
558
|
+
return {
|
|
559
|
+
specs: [],
|
|
560
|
+
error: failBatchSpec("epic.expand", `Subtask spec ${index + 1} is missing a title.`, {
|
|
561
|
+
option: "subtask",
|
|
562
|
+
index,
|
|
563
|
+
rawSpec,
|
|
564
|
+
}),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (description.trim().length === 0) {
|
|
569
|
+
return {
|
|
570
|
+
specs: [],
|
|
571
|
+
error: failEmptyCompactField("epic.expand", "subtask", index, rawSpec, "description"),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
seenTempKeys.add(tempKey);
|
|
576
|
+
const spec: CompactSubtaskSpec = status.length > 0
|
|
577
|
+
? { parent, tempKey, title, description, status }
|
|
578
|
+
: { parent, tempKey, title, description };
|
|
579
|
+
specs.push(spec);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return { specs };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function parseExpandDependencySpecs(rawSpecs: readonly string[]): { specs: CompactDependencySpec[]; error?: CliResult } {
|
|
586
|
+
const specs: CompactDependencySpec[] = [];
|
|
587
|
+
|
|
588
|
+
for (const [index, rawSpec] of rawSpecs.entries()) {
|
|
589
|
+
const parsed = parseCompactFields(rawSpec);
|
|
590
|
+
if (parsed.invalidEscape !== null) {
|
|
591
|
+
return {
|
|
592
|
+
specs: [],
|
|
593
|
+
error: failBatchSpec("epic.expand", `Invalid escape sequence ${parsed.invalidEscape} in --dep spec ${index + 1}.`, {
|
|
594
|
+
option: "dep",
|
|
595
|
+
index,
|
|
596
|
+
rawSpec,
|
|
597
|
+
}),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (parsed.hasDanglingEscape) {
|
|
602
|
+
return {
|
|
603
|
+
specs: [],
|
|
604
|
+
error: failBatchSpec("epic.expand", `Trailing escape in --dep spec ${index + 1}.`, {
|
|
605
|
+
option: "dep",
|
|
606
|
+
index,
|
|
607
|
+
rawSpec,
|
|
608
|
+
}),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (parsed.fields.length !== 2) {
|
|
613
|
+
return {
|
|
614
|
+
specs: [],
|
|
615
|
+
error: failBatchSpec("epic.expand", `Dependency specs must use <source-ref>|<depends-on-ref> in --dep spec ${index + 1}.`, {
|
|
616
|
+
option: "dep",
|
|
617
|
+
index,
|
|
618
|
+
rawSpec,
|
|
619
|
+
fields: parsed.fields,
|
|
620
|
+
}),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const source = parseCompactEntityRef(parsed.fields[0] ?? "");
|
|
625
|
+
const sourceError = validateCompactEntityRef("epic.expand", "dep", index, rawSpec, "Source ref", source);
|
|
626
|
+
if (sourceError !== undefined) {
|
|
627
|
+
return { specs: [], error: sourceError };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const dependsOn = parseCompactEntityRef(parsed.fields[1] ?? "");
|
|
631
|
+
const dependsOnError = validateCompactEntityRef("epic.expand", "dep", index, rawSpec, "Depends-on ref", dependsOn);
|
|
632
|
+
if (dependsOnError !== undefined) {
|
|
633
|
+
return { specs: [], error: dependsOnError };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
specs.push({ source, dependsOn });
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return { specs };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function findDuplicateExpandTempKey(tasks: readonly CompactTaskSpec[], subtasks: readonly CompactSubtaskSpec[]): string | null {
|
|
643
|
+
const seen = new Set<string>();
|
|
644
|
+
for (const task of tasks) {
|
|
645
|
+
seen.add(task.tempKey);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
for (const subtask of subtasks) {
|
|
649
|
+
if (seen.has(subtask.tempKey)) {
|
|
650
|
+
return subtask.tempKey;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
seen.add(subtask.tempKey);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
|
|
277
659
|
export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
278
|
-
|
|
660
|
+
let database: TrekoonDatabase | undefined;
|
|
279
661
|
|
|
280
662
|
try {
|
|
663
|
+
database = openTrekoonDatabase(context.cwd);
|
|
281
664
|
const parsed = parseArgs(context.args);
|
|
282
665
|
const subcommand: string | undefined = parsed.positional[0];
|
|
283
666
|
const domain = new TrackerDomain(database.db);
|
|
@@ -285,26 +668,161 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
285
668
|
|
|
286
669
|
switch (subcommand) {
|
|
287
670
|
case "create": {
|
|
288
|
-
|
|
671
|
+
const missingCreateOption =
|
|
672
|
+
readMissingOptionValue(parsed.missingOptionValues, "title", "t") ??
|
|
289
673
|
readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
|
|
290
|
-
readMissingOptionValue(parsed.missingOptionValues, "description", "d")
|
|
674
|
+
readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
|
|
675
|
+
readMissingOptionValue(parsed.missingOptionValues, "task", "subtask", "dep");
|
|
291
676
|
if (missingCreateOption !== undefined) {
|
|
292
677
|
return failMissingOptionValue("epic.create", missingCreateOption);
|
|
293
678
|
}
|
|
294
679
|
|
|
680
|
+
const createUnknownOption = findUnknownOption(parsed, CREATE_OPTIONS);
|
|
681
|
+
if (createUnknownOption !== undefined) {
|
|
682
|
+
return unknownOption("epic.create", createUnknownOption, CREATE_OPTIONS);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const unexpectedPositionals = readUnexpectedPositionals(parsed, 1);
|
|
686
|
+
if (unexpectedPositionals.length > 0) {
|
|
687
|
+
return failUnexpectedPositionals("epic.create", unexpectedPositionals);
|
|
688
|
+
}
|
|
689
|
+
|
|
295
690
|
const title: string | undefined = readOption(parsed.options, "title", "t");
|
|
296
691
|
const description: string | undefined = readOption(parsed.options, "description", "d");
|
|
297
692
|
const status: string | undefined = readOption(parsed.options, "status", "s");
|
|
298
|
-
|
|
693
|
+
|
|
694
|
+
const taskSpecs = readOptions(parsed.optionEntries, "task");
|
|
695
|
+
const subtaskSpecs = readOptions(parsed.optionEntries, "subtask");
|
|
696
|
+
const dependencySpecs = readOptions(parsed.optionEntries, "dep");
|
|
697
|
+
|
|
698
|
+
if (taskSpecs.length === 0 && subtaskSpecs.length === 0 && dependencySpecs.length === 0) {
|
|
699
|
+
const epic = mutations.createEpic({
|
|
700
|
+
title: title ?? "",
|
|
701
|
+
description: description ?? "",
|
|
702
|
+
status,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
return okResult({
|
|
706
|
+
command: "epic.create",
|
|
707
|
+
human: `Created epic ${formatEpic(epic)}`,
|
|
708
|
+
data: { epic },
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const parsedTasks = parseExpandTaskSpecs(taskSpecs);
|
|
713
|
+
if (parsedTasks.error !== undefined) {
|
|
714
|
+
return parsedTasks.error;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const parsedSubtasks = parseExpandSubtaskSpecs(subtaskSpecs);
|
|
718
|
+
if (parsedSubtasks.error !== undefined) {
|
|
719
|
+
return parsedSubtasks.error;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const parsedDeps = parseExpandDependencySpecs(dependencySpecs);
|
|
723
|
+
if (parsedDeps.error !== undefined) {
|
|
724
|
+
return parsedDeps.error;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const duplicateTempKey = findDuplicateExpandTempKey(parsedTasks.specs, parsedSubtasks.specs);
|
|
728
|
+
if (duplicateTempKey !== null) {
|
|
729
|
+
return failBatchSpec("epic.create", `Duplicate temp key '${duplicateTempKey}' across --task and --subtask specs.`, {
|
|
730
|
+
tempKey: duplicateTempKey,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const created = mutations.createEpicGraph({
|
|
299
735
|
title: title ?? "",
|
|
300
736
|
description: description ?? "",
|
|
301
737
|
status,
|
|
738
|
+
taskSpecs: parsedTasks.specs,
|
|
739
|
+
subtaskSpecs: parsedSubtasks.specs,
|
|
740
|
+
dependencySpecs: parsedDeps.specs,
|
|
302
741
|
});
|
|
303
742
|
|
|
304
743
|
return okResult({
|
|
305
744
|
command: "epic.create",
|
|
306
|
-
human: `Created epic ${formatEpic(epic)}
|
|
307
|
-
data: {
|
|
745
|
+
human: `Created epic ${formatEpic(created.epic)} with ${created.tasks.length} task(s), ${created.subtasks.length} subtask(s), and ${created.dependencies.length} dependenc${created.dependencies.length === 1 ? "y" : "ies"}.`,
|
|
746
|
+
data: {
|
|
747
|
+
epic: created.epic,
|
|
748
|
+
tasks: created.tasks,
|
|
749
|
+
subtasks: created.subtasks,
|
|
750
|
+
dependencies: created.dependencies,
|
|
751
|
+
result: created.result,
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
case "expand": {
|
|
756
|
+
const expandUnknownOption = findUnknownOption(parsed, EXPAND_OPTIONS);
|
|
757
|
+
if (expandUnknownOption !== undefined) {
|
|
758
|
+
return unknownOption("epic.expand", expandUnknownOption, EXPAND_OPTIONS);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const missingExpandOption = readMissingOptionValue(parsed.missingOptionValues, "task", "subtask", "dep");
|
|
762
|
+
if (missingExpandOption !== undefined) {
|
|
763
|
+
return failMissingOptionValue("epic.expand", missingExpandOption);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const unexpectedPositionals = readUnexpectedPositionals(parsed, 2);
|
|
767
|
+
if (unexpectedPositionals.length > 0) {
|
|
768
|
+
return failUnexpectedPositionals("epic.expand", unexpectedPositionals);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const epicId: string = parsed.positional[1] ?? "";
|
|
772
|
+
if (epicId.trim().length === 0) {
|
|
773
|
+
return failBatchSpec("epic.expand", "Provide an epic id for epic expand.", {
|
|
774
|
+
id: epicId,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const taskSpecs = readOptions(parsed.optionEntries, "task");
|
|
779
|
+
const subtaskSpecs = readOptions(parsed.optionEntries, "subtask");
|
|
780
|
+
const dependencySpecs = readOptions(parsed.optionEntries, "dep");
|
|
781
|
+
if (taskSpecs.length === 0 && subtaskSpecs.length === 0 && dependencySpecs.length === 0) {
|
|
782
|
+
return failBatchSpec("epic.expand", "Provide at least one --task, --subtask, or --dep spec.", {});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const parsedTasks = parseExpandTaskSpecs(taskSpecs);
|
|
786
|
+
if (parsedTasks.error !== undefined) {
|
|
787
|
+
return parsedTasks.error;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const parsedSubtasks = parseExpandSubtaskSpecs(subtaskSpecs);
|
|
791
|
+
if (parsedSubtasks.error !== undefined) {
|
|
792
|
+
return parsedSubtasks.error;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const parsedDeps = parseExpandDependencySpecs(dependencySpecs);
|
|
796
|
+
if (parsedDeps.error !== undefined) {
|
|
797
|
+
return parsedDeps.error;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const duplicateTempKey = findDuplicateExpandTempKey(parsedTasks.specs, parsedSubtasks.specs);
|
|
801
|
+
if (duplicateTempKey !== null) {
|
|
802
|
+
return failBatchSpec("epic.expand", `Duplicate temp key '${duplicateTempKey}' across --task and --subtask specs.`, {
|
|
803
|
+
tempKey: duplicateTempKey,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const created = mutations.expandEpic({
|
|
808
|
+
epicId,
|
|
809
|
+
taskSpecs: parsedTasks.specs,
|
|
810
|
+
subtaskSpecs: parsedSubtasks.specs,
|
|
811
|
+
dependencySpecs: parsedDeps.specs,
|
|
812
|
+
});
|
|
813
|
+
const result: CompactBatchResultContract & {
|
|
814
|
+
counts: { tasks: number; subtasks: number; dependencies: number };
|
|
815
|
+
} = created.result;
|
|
816
|
+
return okResult({
|
|
817
|
+
command: "epic.expand",
|
|
818
|
+
human: `Expanded epic ${epicId} with ${created.tasks.length} task(s), ${created.subtasks.length} subtask(s), and ${created.dependencies.length} dependenc${created.dependencies.length === 1 ? "y" : "ies"}.`,
|
|
819
|
+
data: {
|
|
820
|
+
epicId,
|
|
821
|
+
tasks: created.tasks,
|
|
822
|
+
subtasks: created.subtasks,
|
|
823
|
+
dependencies: created.dependencies,
|
|
824
|
+
result,
|
|
825
|
+
},
|
|
308
826
|
});
|
|
309
827
|
}
|
|
310
828
|
case "list": {
|
|
@@ -520,6 +1038,134 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
520
1038
|
}),
|
|
521
1039
|
});
|
|
522
1040
|
}
|
|
1041
|
+
case "search": {
|
|
1042
|
+
const searchUnknownOption = findUnknownOption(parsed, SEARCH_OPTIONS);
|
|
1043
|
+
if (searchUnknownOption !== undefined) {
|
|
1044
|
+
return unknownOption("epic.search", searchUnknownOption, SEARCH_OPTIONS);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const missingSearchOption = readMissingOptionValue(parsed.missingOptionValues, "fields");
|
|
1048
|
+
if (missingSearchOption !== undefined) {
|
|
1049
|
+
return failMissingOptionValue("epic.search", missingSearchOption);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const epicId: string = parsed.positional[1] ?? "";
|
|
1053
|
+
const searchText: string = parsed.positional[2] ?? "";
|
|
1054
|
+
if (epicId.length === 0 || searchText.trim().length === 0) {
|
|
1055
|
+
return invalidSearchInput(
|
|
1056
|
+
"epic.search",
|
|
1057
|
+
"Usage: trekoon epic search <epic-id> \"search text\" [--fields <csv>] [--preview]",
|
|
1058
|
+
"Missing search target",
|
|
1059
|
+
{
|
|
1060
|
+
epicId,
|
|
1061
|
+
},
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const parsedFields = parseCsvEnumOption(readOption(parsed.options, "fields"), SEARCH_REPLACE_FIELDS);
|
|
1066
|
+
if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
|
|
1067
|
+
return invalidSearchInput("epic.search", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
|
|
1068
|
+
fields: readOption(parsed.options, "fields"),
|
|
1069
|
+
invalidFields: parsedFields.invalidValues,
|
|
1070
|
+
allowedFields: [...SEARCH_REPLACE_FIELDS],
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const { matches, summary } = domain.searchEpicScope(epicId, searchText, parsedFields.values);
|
|
1075
|
+
|
|
1076
|
+
return okResult({
|
|
1077
|
+
command: "epic.search",
|
|
1078
|
+
human: formatSearchHuman(matches, "No matches found."),
|
|
1079
|
+
data: {
|
|
1080
|
+
scope: {
|
|
1081
|
+
kind: "epic",
|
|
1082
|
+
id: epicId,
|
|
1083
|
+
},
|
|
1084
|
+
query: {
|
|
1085
|
+
search: searchText,
|
|
1086
|
+
fields: parsedFields.values,
|
|
1087
|
+
mode: "preview",
|
|
1088
|
+
},
|
|
1089
|
+
summary,
|
|
1090
|
+
matches,
|
|
1091
|
+
},
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
case "replace": {
|
|
1095
|
+
const replaceUnknownOption = findUnknownOption(parsed, REPLACE_OPTIONS);
|
|
1096
|
+
if (replaceUnknownOption !== undefined) {
|
|
1097
|
+
return unknownOption("epic.replace", replaceUnknownOption, REPLACE_OPTIONS);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const missingReplaceOption =
|
|
1101
|
+
readMissingOptionValue(parsed.missingOptionValues, "search") ??
|
|
1102
|
+
readMissingOptionValue(parsed.missingOptionValues, "replace") ??
|
|
1103
|
+
readMissingOptionValue(parsed.missingOptionValues, "fields");
|
|
1104
|
+
if (missingReplaceOption !== undefined) {
|
|
1105
|
+
return failMissingOptionValue("epic.replace", missingReplaceOption);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const epicId: string = parsed.positional[1] ?? "";
|
|
1109
|
+
const searchText = readOption(parsed.options, "search") ?? "";
|
|
1110
|
+
const replacementText = readOption(parsed.options, "replace") ?? "";
|
|
1111
|
+
if (epicId.length === 0 || searchText.trim().length === 0) {
|
|
1112
|
+
return invalidSearchInput(
|
|
1113
|
+
"epic.replace",
|
|
1114
|
+
"Usage: trekoon epic replace <epic-id> --search \"text\" --replace \"text\" [--fields <csv>] [--preview|--apply]",
|
|
1115
|
+
"Missing replace target",
|
|
1116
|
+
{
|
|
1117
|
+
epicId,
|
|
1118
|
+
search: searchText,
|
|
1119
|
+
},
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const rawFields = readOption(parsed.options, "fields");
|
|
1124
|
+
const parsedFields = parseCsvEnumOption(rawFields, SEARCH_REPLACE_FIELDS);
|
|
1125
|
+
if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
|
|
1126
|
+
return invalidSearchInput("epic.replace", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
|
|
1127
|
+
fields: rawFields,
|
|
1128
|
+
invalidFields: parsedFields.invalidValues,
|
|
1129
|
+
allowedFields: [...SEARCH_REPLACE_FIELDS],
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const previewMode = resolvePreviewApplyMode(parsed.flags);
|
|
1134
|
+
if (previewMode.conflict) {
|
|
1135
|
+
return invalidSearchInput("epic.replace", "Use either --preview or --apply, not both.", "Conflicting mode flags", {
|
|
1136
|
+
flags: ["preview", "apply"],
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const replacementSummary = previewMode.mode === "apply"
|
|
1141
|
+
? mutations.applyEpicReplacement(epicId, searchText, replacementText, parsedFields.values)
|
|
1142
|
+
: mutations.previewEpicReplacement(epicId, searchText, replacementText, parsedFields.values);
|
|
1143
|
+
const { matches, summary: matchSummary } = replacementSummary;
|
|
1144
|
+
|
|
1145
|
+
const summary = {
|
|
1146
|
+
...matchSummary,
|
|
1147
|
+
mode: previewMode.mode,
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
return okResult({
|
|
1151
|
+
command: "epic.replace",
|
|
1152
|
+
human: formatSearchHuman(matches, `No ${previewMode.mode === "apply" ? "replacements" : "matches"} found.`),
|
|
1153
|
+
data: {
|
|
1154
|
+
scope: {
|
|
1155
|
+
kind: "epic",
|
|
1156
|
+
id: epicId,
|
|
1157
|
+
},
|
|
1158
|
+
query: {
|
|
1159
|
+
search: searchText,
|
|
1160
|
+
replace: replacementText,
|
|
1161
|
+
fields: parsedFields.values,
|
|
1162
|
+
mode: previewMode.mode,
|
|
1163
|
+
},
|
|
1164
|
+
summary,
|
|
1165
|
+
matches,
|
|
1166
|
+
},
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
523
1169
|
case "update": {
|
|
524
1170
|
const missingUpdateOption =
|
|
525
1171
|
readMissingOptionValue(parsed.missingOptionValues, "ids") ??
|
|
@@ -657,7 +1303,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
657
1303
|
default:
|
|
658
1304
|
return failResult({
|
|
659
1305
|
command: "epic",
|
|
660
|
-
human: "Usage: trekoon epic <create|list|show|update|delete>",
|
|
1306
|
+
human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete>",
|
|
661
1307
|
data: {
|
|
662
1308
|
args: context.args,
|
|
663
1309
|
},
|
|
@@ -670,6 +1316,6 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
670
1316
|
} catch (error: unknown) {
|
|
671
1317
|
return failFromError(error, "epic");
|
|
672
1318
|
} finally {
|
|
673
|
-
database
|
|
1319
|
+
database?.close();
|
|
674
1320
|
}
|
|
675
1321
|
}
|