trekoon 0.2.0 → 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.
@@ -2,24 +2,29 @@ import {
2
2
  SEARCH_REPLACE_FIELDS,
3
3
  findUnknownOption,
4
4
  hasFlag,
5
+ isValidCompactTempKey,
5
6
  parseArgs,
7
+ parseCompactFields,
6
8
  parseCsvEnumOption,
7
9
  parseStrictNonNegativeInt,
8
10
  parseStrictPositiveInt,
9
11
  readEnumOption,
10
12
  readMissingOptionValue,
11
13
  readOption,
14
+ readOptions,
15
+ readUnexpectedPositionals,
12
16
  resolvePreviewApplyMode,
13
17
  suggestOptions,
14
18
  } from "./arg-parser";
19
+ import { unexpectedFailureResult } from "./error-utils";
15
20
 
16
21
  import { MutationService } from "../domain/mutation-service";
17
22
  import { TrackerDomain } from "../domain/tracker-domain";
18
- import { DomainError, type SearchEntityMatch, type TaskRecord } from "../domain/types";
23
+ import { type CompactBatchResultContract, type CompactTaskSpec, type SearchEntityMatch, type TaskRecord } from "../domain/types";
19
24
  import { formatHumanTable } from "../io/human-table";
20
25
  import { failResult, okResult } from "../io/output";
21
26
  import { type CliContext, type CliResult } from "../runtime/command-types";
22
- import { openTrekoonDatabase } from "../storage/database";
27
+ import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
23
28
 
24
29
  function formatTask(task: TaskRecord): string {
25
30
  return `${task.id} | epic=${task.epicId} | ${task.title} | ${task.status}`;
@@ -33,6 +38,7 @@ const READY_REASON_READY = "all_dependencies_done";
33
38
  const READY_REASON_BLOCKED = "blocked_by_dependencies";
34
39
  const SEARCH_OPTIONS = ["fields", "preview"] as const;
35
40
  const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
41
+ const CREATE_MANY_OPTIONS = ["epic", "e", "task"] as const;
36
42
 
37
43
  interface DependencyBlocker {
38
44
  readonly id: string;
@@ -383,29 +389,9 @@ function formatTaskShowTable(taskTree: {
383
389
  }
384
390
 
385
391
  function failFromError(error: unknown): CliResult {
386
- if (error instanceof DomainError) {
387
- return failResult({
388
- command: "task",
389
- human: error.message,
390
- data: {
391
- code: error.code,
392
- ...(error.details ?? {}),
393
- },
394
- error: {
395
- code: error.code,
396
- message: error.message,
397
- },
398
- });
399
- }
400
-
401
- return failResult({
392
+ return unexpectedFailureResult(error, {
402
393
  command: "task",
403
394
  human: "Unexpected task command failure",
404
- data: {},
405
- error: {
406
- code: "internal_error",
407
- message: "Unexpected task command failure",
408
- },
409
395
  });
410
396
  }
411
397
 
@@ -424,10 +410,145 @@ function failMissingOptionValue(command: string, option: string): CliResult {
424
410
  });
425
411
  }
426
412
 
413
+ function failBatchSpec(command: string, human: string, data: Record<string, unknown>): CliResult {
414
+ return failResult({
415
+ command,
416
+ human,
417
+ data,
418
+ error: {
419
+ code: "invalid_input",
420
+ message: human,
421
+ },
422
+ });
423
+ }
424
+
425
+ function failUnexpectedPositionals(command: string, unexpected: readonly string[]): CliResult {
426
+ return failBatchSpec(command, `Unexpected positional arguments: ${unexpected.join(", ")}.`, {
427
+ unexpectedPositionals: unexpected,
428
+ });
429
+ }
430
+
431
+ function failEmptyCompactField(command: string, option: string, index: number, rawSpec: string, field: string): CliResult {
432
+ return failBatchSpec(command, `${option === "task" ? "Task" : "Spec"} spec ${index + 1} is missing a ${field}.`, {
433
+ option,
434
+ index,
435
+ rawSpec,
436
+ field,
437
+ });
438
+ }
439
+
440
+ function parseTaskCreateManySpecs(rawSpecs: readonly string[]): { specs: CompactTaskSpec[]; error?: CliResult } {
441
+ const specs: CompactTaskSpec[] = [];
442
+ const seenTempKeys = new Set<string>();
443
+
444
+ for (const [index, rawSpec] of rawSpecs.entries()) {
445
+ const parsed = parseCompactFields(rawSpec);
446
+ if (parsed.invalidEscape !== null) {
447
+ return {
448
+ specs: [],
449
+ error: failBatchSpec("task.create-many", `Invalid escape sequence ${parsed.invalidEscape} in --task spec ${index + 1}.`, {
450
+ option: "task",
451
+ index,
452
+ rawSpec,
453
+ invalidEscape: parsed.invalidEscape,
454
+ }),
455
+ };
456
+ }
457
+
458
+ if (parsed.hasDanglingEscape) {
459
+ return {
460
+ specs: [],
461
+ error: failBatchSpec("task.create-many", `Trailing escape in --task spec ${index + 1}.`, {
462
+ option: "task",
463
+ index,
464
+ rawSpec,
465
+ }),
466
+ };
467
+ }
468
+
469
+ if (parsed.fields.length !== 4) {
470
+ return {
471
+ specs: [],
472
+ error: failBatchSpec("task.create-many", `Task specs must use <temp-key>|<title>|<description>|<status> in --task spec ${index + 1}.`, {
473
+ option: "task",
474
+ index,
475
+ rawSpec,
476
+ fields: parsed.fields,
477
+ }),
478
+ };
479
+ }
480
+
481
+ const tempKey = parsed.fields[0] ?? "";
482
+ const title = parsed.fields[1] ?? "";
483
+ const description = parsed.fields[2] ?? "";
484
+ const status = parsed.fields[3] ?? "";
485
+ if (!tempKey || !isValidCompactTempKey(tempKey)) {
486
+ return {
487
+ specs: [],
488
+ error: failBatchSpec("task.create-many", `Task spec ${index + 1} must start with a temp key like seed-1.`, {
489
+ option: "task",
490
+ index,
491
+ rawSpec,
492
+ tempKey,
493
+ }),
494
+ };
495
+ }
496
+
497
+ if (seenTempKeys.has(tempKey)) {
498
+ return {
499
+ specs: [],
500
+ error: failBatchSpec("task.create-many", `Duplicate temp key '${tempKey}' in --task specs.`, {
501
+ option: "task",
502
+ index,
503
+ rawSpec,
504
+ tempKey,
505
+ }),
506
+ };
507
+ }
508
+
509
+ if (!title || title.trim().length === 0) {
510
+ return {
511
+ specs: [],
512
+ error: failBatchSpec("task.create-many", `Task spec ${index + 1} is missing a title.`, {
513
+ option: "task",
514
+ index,
515
+ rawSpec,
516
+ }),
517
+ };
518
+ }
519
+
520
+ if (description.trim().length === 0) {
521
+ return {
522
+ specs: [],
523
+ error: failEmptyCompactField("task.create-many", "task", index, rawSpec, "description"),
524
+ };
525
+ }
526
+
527
+ seenTempKeys.add(tempKey);
528
+ const spec: CompactTaskSpec = status.length > 0
529
+ ? {
530
+ tempKey,
531
+ title,
532
+ description,
533
+ status,
534
+ }
535
+ : {
536
+ tempKey,
537
+ title,
538
+ description,
539
+ };
540
+
541
+ specs.push(spec);
542
+ }
543
+
544
+ return { specs };
545
+ }
546
+
427
547
  export async function runTask(context: CliContext): Promise<CliResult> {
428
- const database = openTrekoonDatabase(context.cwd);
548
+ let database: TrekoonDatabase | undefined;
429
549
 
430
550
  try {
551
+ database = openTrekoonDatabase(context.cwd);
431
552
  const parsed = parseArgs(context.args);
432
553
  const subcommand: string | undefined = parsed.positional[0];
433
554
  const domain = new TrackerDomain(database.db);
@@ -460,6 +581,56 @@ export async function runTask(context: CliContext): Promise<CliResult> {
460
581
  data: { task },
461
582
  });
462
583
  }
584
+ case "create-many": {
585
+ const createManyUnknownOption = findUnknownOption(parsed, CREATE_MANY_OPTIONS);
586
+ if (createManyUnknownOption !== undefined) {
587
+ return unknownOption("task.create-many", createManyUnknownOption, CREATE_MANY_OPTIONS);
588
+ }
589
+
590
+ const missingCreateManyOption = readMissingOptionValue(parsed.missingOptionValues, "epic", "e", "task");
591
+ if (missingCreateManyOption !== undefined) {
592
+ return failMissingOptionValue("task.create-many", missingCreateManyOption);
593
+ }
594
+
595
+ const unexpectedPositionals = readUnexpectedPositionals(parsed, 1);
596
+ if (unexpectedPositionals.length > 0) {
597
+ return failUnexpectedPositionals("task.create-many", unexpectedPositionals);
598
+ }
599
+
600
+ const epicId = readOption(parsed.options, "epic", "e");
601
+ if (epicId === undefined || epicId.trim().length === 0) {
602
+ return failBatchSpec("task.create-many", "Provide --epic for task create-many.", {
603
+ option: "epic",
604
+ });
605
+ }
606
+
607
+ const rawSpecs = readOptions(parsed.optionEntries, "task");
608
+ if (rawSpecs.length === 0) {
609
+ return failBatchSpec("task.create-many", "Provide at least one --task spec.", {
610
+ option: "task",
611
+ });
612
+ }
613
+
614
+ const specResult = parseTaskCreateManySpecs(rawSpecs);
615
+ if (specResult.error !== undefined) {
616
+ return specResult.error;
617
+ }
618
+
619
+ const created = mutations.createTaskBatch({
620
+ epicId,
621
+ specs: specResult.specs,
622
+ });
623
+ const result: CompactBatchResultContract = created.result;
624
+ return okResult({
625
+ command: "task.create-many",
626
+ human: `Created ${created.tasks.length} task(s): ${created.tasks.map(formatTask).join("\n")}`,
627
+ data: {
628
+ epicId,
629
+ tasks: created.tasks,
630
+ result,
631
+ },
632
+ });
633
+ }
463
634
  case "list": {
464
635
  const missingListOption =
465
636
  readMissingOptionValue(parsed.missingOptionValues, "view") ??
@@ -1084,7 +1255,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1084
1255
  default:
1085
1256
  return failResult({
1086
1257
  command: "task",
1087
- human: "Usage: trekoon task <create|list|show|ready|next|search|replace|update|delete>",
1258
+ human: "Usage: trekoon task <create|create-many|list|show|ready|next|search|replace|update|delete>",
1088
1259
  data: {
1089
1260
  args: context.args,
1090
1261
  },
@@ -1097,6 +1268,6 @@ export async function runTask(context: CliContext): Promise<CliResult> {
1097
1268
  } catch (error: unknown) {
1098
1269
  return failFromError(error);
1099
1270
  } finally {
1100
- database.close();
1271
+ database?.close();
1101
1272
  }
1102
1273
  }
@@ -4,6 +4,14 @@ import { appendEventWithGitContext } from "../sync/event-writes";
4
4
  import { ENTITY_OPERATIONS } from "./mutation-operations";
5
5
  import { TrackerDomain } from "./tracker-domain";
6
6
  import {
7
+ type CompactEpicCreateResult,
8
+ type CompactEpicExpandResult,
9
+ type CompactDependencyBatchAddResult,
10
+ type CompactDependencySpec,
11
+ type CompactSubtaskBatchCreateResult,
12
+ type CompactSubtaskSpec,
13
+ type CompactTaskBatchCreateResult,
14
+ type CompactTaskSpec,
7
15
  type DependencyRecord,
8
16
  type EpicRecord,
9
17
  type SearchEntityMatch,
@@ -104,6 +112,66 @@ export class MutationService {
104
112
  })();
105
113
  }
106
114
 
115
+ createEpicGraph(input: {
116
+ title: string;
117
+ description: string;
118
+ status?: string | undefined;
119
+ taskSpecs: readonly CompactTaskSpec[];
120
+ subtaskSpecs: readonly CompactSubtaskSpec[];
121
+ dependencySpecs: readonly CompactDependencySpec[];
122
+ }): CompactEpicCreateResult {
123
+ return this.#db.transaction((): CompactEpicCreateResult => {
124
+ const epic = this.#domain.createEpic(input);
125
+ const created = this.#domain.expandEpic({
126
+ epicId: epic.id,
127
+ taskSpecs: input.taskSpecs,
128
+ subtaskSpecs: input.subtaskSpecs,
129
+ dependencySpecs: input.dependencySpecs,
130
+ });
131
+
132
+ this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
133
+ title: epic.title,
134
+ description: epic.description,
135
+ status: epic.status,
136
+ });
137
+
138
+ for (const task of created.tasks) {
139
+ this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
140
+ epic_id: task.epicId,
141
+ title: task.title,
142
+ description: task.description,
143
+ status: task.status,
144
+ });
145
+ }
146
+
147
+ for (const subtask of created.subtasks) {
148
+ this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
149
+ task_id: subtask.taskId,
150
+ title: subtask.title,
151
+ description: subtask.description,
152
+ status: subtask.status,
153
+ });
154
+ }
155
+
156
+ for (const dependency of created.dependencies) {
157
+ this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
158
+ source_id: dependency.sourceId,
159
+ source_kind: dependency.sourceKind,
160
+ depends_on_id: dependency.dependsOnId,
161
+ depends_on_kind: dependency.dependsOnKind,
162
+ });
163
+ }
164
+
165
+ return {
166
+ epic,
167
+ tasks: created.tasks,
168
+ subtasks: created.subtasks,
169
+ dependencies: created.dependencies,
170
+ result: created.result,
171
+ };
172
+ })();
173
+ }
174
+
107
175
  updateEpic(
108
176
  id: string,
109
177
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
@@ -139,6 +207,60 @@ export class MutationService {
139
207
  })();
140
208
  }
141
209
 
210
+ createTaskBatch(input: { epicId: string; specs: readonly CompactTaskSpec[] }): CompactTaskBatchCreateResult {
211
+ return this.#db.transaction((): CompactTaskBatchCreateResult => {
212
+ const created = this.#domain.createTaskBatch(input);
213
+ for (const task of created.tasks) {
214
+ this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
215
+ epic_id: task.epicId,
216
+ title: task.title,
217
+ description: task.description,
218
+ status: task.status,
219
+ });
220
+ }
221
+ return created;
222
+ })();
223
+ }
224
+
225
+ expandEpic(input: {
226
+ epicId: string;
227
+ taskSpecs: readonly CompactTaskSpec[];
228
+ subtaskSpecs: readonly CompactSubtaskSpec[];
229
+ dependencySpecs: readonly CompactDependencySpec[];
230
+ }): CompactEpicExpandResult {
231
+ return this.#db.transaction((): CompactEpicExpandResult => {
232
+ const created = this.#domain.expandEpic(input);
233
+ for (const task of created.tasks) {
234
+ this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
235
+ epic_id: task.epicId,
236
+ title: task.title,
237
+ description: task.description,
238
+ status: task.status,
239
+ });
240
+ }
241
+
242
+ for (const subtask of created.subtasks) {
243
+ this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
244
+ task_id: subtask.taskId,
245
+ title: subtask.title,
246
+ description: subtask.description,
247
+ status: subtask.status,
248
+ });
249
+ }
250
+
251
+ for (const dependency of created.dependencies) {
252
+ this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
253
+ source_id: dependency.sourceId,
254
+ source_kind: dependency.sourceKind,
255
+ depends_on_id: dependency.dependsOnId,
256
+ depends_on_kind: dependency.dependsOnKind,
257
+ });
258
+ }
259
+
260
+ return created;
261
+ })();
262
+ }
263
+
142
264
  updateTask(
143
265
  id: string,
144
266
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
@@ -180,6 +302,21 @@ export class MutationService {
180
302
  })();
181
303
  }
182
304
 
305
+ createSubtaskBatch(input: { taskId: string; specs: readonly CompactSubtaskSpec[] }): CompactSubtaskBatchCreateResult {
306
+ return this.#db.transaction((): CompactSubtaskBatchCreateResult => {
307
+ const created = this.#domain.createSubtaskBatch(input);
308
+ for (const subtask of created.subtasks) {
309
+ this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
310
+ task_id: subtask.taskId,
311
+ title: subtask.title,
312
+ description: subtask.description,
313
+ status: subtask.status,
314
+ });
315
+ }
316
+ return created;
317
+ })();
318
+ }
319
+
183
320
  updateSubtask(
184
321
  id: string,
185
322
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
@@ -216,6 +353,21 @@ export class MutationService {
216
353
  })();
217
354
  }
218
355
 
356
+ addDependencyBatch(input: { specs: readonly CompactDependencySpec[] }): CompactDependencyBatchAddResult {
357
+ return this.#db.transaction((): CompactDependencyBatchAddResult => {
358
+ const created = this.#domain.addDependencyBatch(input);
359
+ for (const dependency of created.dependencies) {
360
+ this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
361
+ source_id: dependency.sourceId,
362
+ source_kind: dependency.sourceKind,
363
+ depends_on_id: dependency.dependsOnId,
364
+ depends_on_kind: dependency.dependsOnKind,
365
+ });
366
+ }
367
+ return created;
368
+ })();
369
+ }
370
+
219
371
  removeDependency(sourceId: string, dependsOnId: string): number {
220
372
  return this.#db.transaction((): number => {
221
373
  const removed = this.#domain.removeDependency(sourceId, dependsOnId);