@nijaru/tk 0.0.4 → 0.0.5
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/package.json +1 -1
- package/src/cli.test.ts +236 -40
- package/src/cli.ts +23 -47
- package/src/db/storage.ts +85 -49
- package/src/lib/completions.ts +23 -8
- package/src/lib/format.test.ts +73 -1
- package/src/lib/format.ts +28 -27
- package/src/lib/help.ts +21 -4
- package/src/lib/time.test.ts +222 -0
- package/src/lib/time.ts +24 -1
- package/src/types.ts +4 -4
package/package.json
CHANGED
package/src/cli.test.ts
CHANGED
|
@@ -57,6 +57,19 @@ describe("tk CLI", () => {
|
|
|
57
57
|
expect(lsHelp).toContain("--status");
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
test("tk mv --help shows mv help", async () => {
|
|
61
|
+
const { stdout, exitCode } = await run(["mv", "--help"], testDir);
|
|
62
|
+
expect(exitCode).toBe(0);
|
|
63
|
+
expect(stdout).toContain("tk mv");
|
|
64
|
+
expect(stdout).toContain("<project>");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("tk move --help shows move help", async () => {
|
|
68
|
+
const { stdout, exitCode } = await run(["move", "--help"], testDir);
|
|
69
|
+
expect(exitCode).toBe(0);
|
|
70
|
+
expect(stdout).toContain("tk move");
|
|
71
|
+
});
|
|
72
|
+
|
|
60
73
|
test("help <command> shows command-specific help", async () => {
|
|
61
74
|
const { stdout, exitCode } = await run(["help", "add"], testDir);
|
|
62
75
|
expect(exitCode).toBe(0);
|
|
@@ -320,6 +333,62 @@ describe("tk CLI", () => {
|
|
|
320
333
|
expect(lines[0]).toContain("Done Second");
|
|
321
334
|
expect(lines[1]).toContain("Done First");
|
|
322
335
|
});
|
|
336
|
+
|
|
337
|
+
test("shows [due today] annotation for task due today", async () => {
|
|
338
|
+
await run(["add", "Due Today Task", "--due", "+0d"], testDir);
|
|
339
|
+
|
|
340
|
+
const { stdout } = await run(["ls"], testDir);
|
|
341
|
+
expect(stdout).toContain("[due today]");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("shows [due Nd] annotation for task due within threshold", async () => {
|
|
345
|
+
await run(["add", "Due Soon Task", "--due", "+3d"], testDir);
|
|
346
|
+
|
|
347
|
+
const { stdout } = await run(["ls"], testDir);
|
|
348
|
+
expect(stdout).toContain("[due 3d]");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("no due-soon annotation for task due beyond threshold", async () => {
|
|
352
|
+
await run(["add", "Far Future Task", "--due", "2099-01-01"], testDir);
|
|
353
|
+
|
|
354
|
+
const { stdout } = await run(["ls"], testDir);
|
|
355
|
+
expect(stdout).not.toContain("[due ");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("no due-soon annotation for overdue task (shows [OVERDUE] instead)", async () => {
|
|
359
|
+
await run(["add", "Overdue Task", "--due", "2020-01-01"], testDir);
|
|
360
|
+
|
|
361
|
+
const { stdout } = await run(["ls"], testDir);
|
|
362
|
+
expect(stdout).toContain("[OVERDUE]");
|
|
363
|
+
expect(stdout).not.toContain("[due today]");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("--json includes days_until_due field", async () => {
|
|
367
|
+
await run(["add", "Due Today", "--due", "+0d"], testDir);
|
|
368
|
+
|
|
369
|
+
const { stdout } = await run(["ls", "--json"], testDir);
|
|
370
|
+
const tasks = JSON.parse(stdout);
|
|
371
|
+
expect(tasks[0]).toHaveProperty("days_until_due");
|
|
372
|
+
expect(tasks[0].days_until_due).toBe(0);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe("show (due-soon)", () => {
|
|
377
|
+
test("shows [due today] in detail view", async () => {
|
|
378
|
+
const { stdout: idOut } = await run(["add", "Due Today", "--due", "+0d"], testDir);
|
|
379
|
+
const id = idOut.trim();
|
|
380
|
+
|
|
381
|
+
const { stdout } = await run(["show", id], testDir);
|
|
382
|
+
expect(stdout).toContain("[due today]");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("shows [due Nd] in detail view", async () => {
|
|
386
|
+
const { stdout: idOut } = await run(["add", "Due Soon", "--due", "+4d"], testDir);
|
|
387
|
+
const id = idOut.trim();
|
|
388
|
+
|
|
389
|
+
const { stdout } = await run(["show", id], testDir);
|
|
390
|
+
expect(stdout).toContain("[due 4d]");
|
|
391
|
+
});
|
|
323
392
|
});
|
|
324
393
|
|
|
325
394
|
describe("ready", () => {
|
|
@@ -604,25 +673,29 @@ describe("tk CLI", () => {
|
|
|
604
673
|
describe("init", () => {
|
|
605
674
|
test("creates .tasks directory", async () => {
|
|
606
675
|
const newDir = mkdtempSync(join(tmpdir(), "tk-init-"));
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
676
|
+
try {
|
|
677
|
+
Bun.spawnSync(["git", "init"], { cwd: newDir });
|
|
678
|
+
|
|
679
|
+
const { stdout, exitCode } = await run(["init"], newDir);
|
|
680
|
+
expect(exitCode).toBe(0);
|
|
681
|
+
expect(stdout).toContain("Initialized");
|
|
682
|
+
expect(existsSync(join(newDir, ".tasks"))).toBe(true);
|
|
683
|
+
} finally {
|
|
684
|
+
rmSync(newDir, { recursive: true, force: true });
|
|
685
|
+
}
|
|
615
686
|
});
|
|
616
687
|
|
|
617
688
|
test("creates .tasks with custom project", async () => {
|
|
618
689
|
const newDir = mkdtempSync(join(tmpdir(), "tk-init-"));
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
690
|
+
try {
|
|
691
|
+
Bun.spawnSync(["git", "init"], { cwd: newDir });
|
|
692
|
+
|
|
693
|
+
await run(["init", "-P", "api"], newDir);
|
|
694
|
+
const { stdout } = await run(["add", "Task"], newDir);
|
|
695
|
+
expect(stdout).toMatch(/^api-[a-z0-9]{4}$/);
|
|
696
|
+
} finally {
|
|
697
|
+
rmSync(newDir, { recursive: true, force: true });
|
|
698
|
+
}
|
|
626
699
|
});
|
|
627
700
|
|
|
628
701
|
test("reports already initialized", async () => {
|
|
@@ -661,15 +734,6 @@ describe("tk CLI", () => {
|
|
|
661
734
|
expect(stdout).toMatch(/^api-[a-z0-9]{4}$/);
|
|
662
735
|
});
|
|
663
736
|
|
|
664
|
-
test("manages aliases", async () => {
|
|
665
|
-
await run(["init"], testDir);
|
|
666
|
-
await run(["config", "alias", "api", "packages/api"], testDir);
|
|
667
|
-
|
|
668
|
-
const { stdout } = await run(["config", "alias"], testDir);
|
|
669
|
-
expect(stdout).toContain("api");
|
|
670
|
-
expect(stdout).toContain("packages/api");
|
|
671
|
-
});
|
|
672
|
-
|
|
673
737
|
test("renames project and updates references", async () => {
|
|
674
738
|
await run(["init", "-P", "old"], testDir);
|
|
675
739
|
|
|
@@ -804,22 +868,14 @@ describe("tk CLI", () => {
|
|
|
804
868
|
});
|
|
805
869
|
|
|
806
870
|
test("ambiguous ID shows matching tasks", async () => {
|
|
807
|
-
//
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
// Only test if both refs start with same char (otherwise not ambiguous)
|
|
817
|
-
if (ref2.startsWith(prefix)) {
|
|
818
|
-
const { stderr, exitCode } = await run(["show", prefix], testDir);
|
|
819
|
-
expect(exitCode).toBe(1);
|
|
820
|
-
expect(stderr).toContain("Ambiguous");
|
|
821
|
-
expect(stderr).toContain("matches");
|
|
822
|
-
}
|
|
871
|
+
// Two tasks in the same project share the "api-" prefix — guaranteed ambiguous
|
|
872
|
+
await run(["add", "Task 1", "-P", "api"], testDir);
|
|
873
|
+
await run(["add", "Task 2", "-P", "api"], testDir);
|
|
874
|
+
|
|
875
|
+
const { stderr, exitCode } = await run(["show", "api"], testDir);
|
|
876
|
+
expect(exitCode).toBe(1);
|
|
877
|
+
expect(stderr).toContain("Ambiguous");
|
|
878
|
+
expect(stderr).toContain("matches");
|
|
823
879
|
});
|
|
824
880
|
|
|
825
881
|
test("not found shows clear error", async () => {
|
|
@@ -973,4 +1029,144 @@ describe("tk CLI", () => {
|
|
|
973
1029
|
expect(result).toHaveProperty("unfixable");
|
|
974
1030
|
});
|
|
975
1031
|
});
|
|
1032
|
+
|
|
1033
|
+
describe("mv", () => {
|
|
1034
|
+
test("moves task to a different project", async () => {
|
|
1035
|
+
await run(["init"], testDir);
|
|
1036
|
+
const { stdout: idOut } = await run(["add", "Move me", "-P", "src"], testDir);
|
|
1037
|
+
const oldId = idOut.trim();
|
|
1038
|
+
const ref = oldId.split("-")[1];
|
|
1039
|
+
|
|
1040
|
+
const { stdout, exitCode } = await run(["mv", oldId, "dst"], testDir);
|
|
1041
|
+
expect(exitCode).toBe(0);
|
|
1042
|
+
const newId = `dst-${ref}`;
|
|
1043
|
+
expect(stdout).toContain(oldId);
|
|
1044
|
+
expect(stdout).toContain(newId);
|
|
1045
|
+
|
|
1046
|
+
// Old ID gone, new ID exists with same content
|
|
1047
|
+
const { exitCode: notFound } = await run(["show", oldId], testDir);
|
|
1048
|
+
expect(notFound).toBe(1);
|
|
1049
|
+
|
|
1050
|
+
const { stdout: showOut } = await run(["show", newId, "--json"], testDir);
|
|
1051
|
+
const task = JSON.parse(showOut);
|
|
1052
|
+
expect(task.project).toBe("dst");
|
|
1053
|
+
expect(task.title).toBe("Move me");
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test("updates blocked_by and parent references", async () => {
|
|
1057
|
+
await run(["init"], testDir);
|
|
1058
|
+
const { stdout: idA } = await run(["add", "A", "-P", "src"], testDir);
|
|
1059
|
+
const { stdout: idB } = await run(["add", "B", "-P", "src"], testDir);
|
|
1060
|
+
const aId = idA.trim();
|
|
1061
|
+
const bId = idB.trim();
|
|
1062
|
+
|
|
1063
|
+
// B is blocked by A, and B has A as parent
|
|
1064
|
+
await run(["block", bId, aId], testDir);
|
|
1065
|
+
await run(["edit", bId, "--parent", aId], testDir);
|
|
1066
|
+
|
|
1067
|
+
const ref = aId.split("-")[1];
|
|
1068
|
+
await run(["mv", aId, "dst"], testDir);
|
|
1069
|
+
const newAId = `dst-${ref}`;
|
|
1070
|
+
|
|
1071
|
+
const { stdout: showB } = await run(["show", bId, "--json"], testDir);
|
|
1072
|
+
const bTask = JSON.parse(showB);
|
|
1073
|
+
expect(bTask.blocked_by).toContain(newAId);
|
|
1074
|
+
expect(bTask.parent).toBe(newAId);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
test("errors when moving to same project", async () => {
|
|
1078
|
+
await run(["init"], testDir);
|
|
1079
|
+
const { stdout: idOut } = await run(["add", "Task", "-P", "api"], testDir);
|
|
1080
|
+
const id = idOut.trim();
|
|
1081
|
+
|
|
1082
|
+
const { stderr, exitCode } = await run(["mv", id, "api"], testDir);
|
|
1083
|
+
expect(exitCode).toBe(1);
|
|
1084
|
+
expect(stderr).toContain("already in project");
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
test("errors on ref collision", async () => {
|
|
1088
|
+
await run(["init"], testDir);
|
|
1089
|
+
const { stdout: idOut } = await run(["add", "Task A", "-P", "src"], testDir);
|
|
1090
|
+
const id = idOut.trim();
|
|
1091
|
+
const ref = id.split("-")[1]!;
|
|
1092
|
+
|
|
1093
|
+
// Manually create a collision in dst project
|
|
1094
|
+
const fs = await import("fs");
|
|
1095
|
+
const path = await import("path");
|
|
1096
|
+
const collisionPath = path.join(testDir, ".tasks", `dst-${ref}.json`);
|
|
1097
|
+
fs.writeFileSync(
|
|
1098
|
+
collisionPath,
|
|
1099
|
+
JSON.stringify({
|
|
1100
|
+
project: "dst",
|
|
1101
|
+
ref,
|
|
1102
|
+
title: "Collision",
|
|
1103
|
+
status: "open",
|
|
1104
|
+
priority: 3,
|
|
1105
|
+
labels: [],
|
|
1106
|
+
assignees: [],
|
|
1107
|
+
parent: null,
|
|
1108
|
+
blocked_by: [],
|
|
1109
|
+
estimate: null,
|
|
1110
|
+
due_date: null,
|
|
1111
|
+
logs: [],
|
|
1112
|
+
description: null,
|
|
1113
|
+
created_at: new Date().toISOString(),
|
|
1114
|
+
updated_at: new Date().toISOString(),
|
|
1115
|
+
completed_at: null,
|
|
1116
|
+
external: {},
|
|
1117
|
+
}),
|
|
1118
|
+
);
|
|
1119
|
+
|
|
1120
|
+
const { stderr, exitCode } = await run(["mv", id, "dst"], testDir);
|
|
1121
|
+
expect(exitCode).toBe(1);
|
|
1122
|
+
expect(stderr).toContain("already exists");
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
test("errors on missing arguments", async () => {
|
|
1126
|
+
await run(["init"], testDir);
|
|
1127
|
+
const { stdout: idOut } = await run(["add", "Task"], testDir);
|
|
1128
|
+
const id = idOut.trim();
|
|
1129
|
+
|
|
1130
|
+
const { stderr: noProject } = await run(["mv", id], testDir);
|
|
1131
|
+
expect(noProject).toContain("Project required");
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
test("errors on invalid project name", async () => {
|
|
1135
|
+
await run(["init"], testDir);
|
|
1136
|
+
const { stdout: idOut } = await run(["add", "Task", "-P", "src"], testDir);
|
|
1137
|
+
const id = idOut.trim();
|
|
1138
|
+
|
|
1139
|
+
const { stderr, exitCode } = await run(["mv", id, "Bad-Project"], testDir);
|
|
1140
|
+
expect(exitCode).toBe(1);
|
|
1141
|
+
expect(stderr).toContain("Invalid project name");
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
test("--json output structure", async () => {
|
|
1145
|
+
await run(["init"], testDir);
|
|
1146
|
+
const { stdout: idOut } = await run(["add", "Task", "-P", "src"], testDir);
|
|
1147
|
+
const id = idOut.trim();
|
|
1148
|
+
|
|
1149
|
+
const { stdout, exitCode } = await run(["mv", id, "dst", "--json"], testDir);
|
|
1150
|
+
expect(exitCode).toBe(0);
|
|
1151
|
+
const result = JSON.parse(stdout);
|
|
1152
|
+
expect(result).toHaveProperty("old_id");
|
|
1153
|
+
expect(result).toHaveProperty("new_id");
|
|
1154
|
+
expect(result).toHaveProperty("referencesUpdated");
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
test("partial ID resolution", async () => {
|
|
1158
|
+
await run(["init"], testDir);
|
|
1159
|
+
const { stdout: idOut } = await run(["add", "Task", "-P", "src"], testDir);
|
|
1160
|
+
const id = idOut.trim();
|
|
1161
|
+
const ref = id.split("-")[1]!;
|
|
1162
|
+
|
|
1163
|
+
// Use just the ref as partial ID
|
|
1164
|
+
const { exitCode } = await run(["mv", ref, "dst"], testDir);
|
|
1165
|
+
expect(exitCode).toBe(0);
|
|
1166
|
+
|
|
1167
|
+
// Verify moved
|
|
1168
|
+
const { exitCode: found } = await run(["show", `dst-${ref}`], testDir);
|
|
1169
|
+
expect(found).toBe(0);
|
|
1170
|
+
});
|
|
1171
|
+
});
|
|
976
1172
|
});
|
package/src/cli.ts
CHANGED
|
@@ -308,8 +308,9 @@ function main() {
|
|
|
308
308
|
const { values } = parseArgs({
|
|
309
309
|
args,
|
|
310
310
|
options: {
|
|
311
|
-
|
|
312
|
-
|
|
311
|
+
project: { type: "string", short: "P" },
|
|
312
|
+
priority: { type: "string", short: "p" },
|
|
313
|
+
label: { type: "string", short: "l" },
|
|
313
314
|
status: { type: "string", short: "s" },
|
|
314
315
|
assignee: { type: "string" },
|
|
315
316
|
parent: { type: "string" },
|
|
@@ -337,7 +338,7 @@ function main() {
|
|
|
337
338
|
status,
|
|
338
339
|
priority,
|
|
339
340
|
project: values.project,
|
|
340
|
-
label: values.label
|
|
341
|
+
label: values.label,
|
|
341
342
|
assignee: values.assignee,
|
|
342
343
|
parent: parentFilter,
|
|
343
344
|
roots: values.roots,
|
|
@@ -402,12 +403,12 @@ function main() {
|
|
|
402
403
|
let labels: string[] | undefined;
|
|
403
404
|
if (values.labels) {
|
|
404
405
|
if (values.labels.startsWith("+")) {
|
|
405
|
-
|
|
406
|
-
|
|
406
|
+
const newLabel = values.labels.slice(1).trim();
|
|
407
|
+
if (!newLabel) throw new Error("Label name required after '+'");
|
|
407
408
|
labels = task.labels.includes(newLabel) ? task.labels : [...task.labels, newLabel];
|
|
408
409
|
} else if (values.labels.startsWith("-")) {
|
|
409
|
-
|
|
410
|
-
|
|
410
|
+
const removeLabel = values.labels.slice(1).trim();
|
|
411
|
+
if (!removeLabel) throw new Error("Label name required after '-'");
|
|
411
412
|
labels = task.labels.filter((l: string) => l !== removeLabel);
|
|
412
413
|
} else {
|
|
413
414
|
// Replace labels
|
|
@@ -430,7 +431,7 @@ function main() {
|
|
|
430
431
|
|
|
431
432
|
const updated = storage.updateTask(task.id, {
|
|
432
433
|
title: values.title?.trim() || undefined,
|
|
433
|
-
description: values.description,
|
|
434
|
+
description: values.description === "-" ? null : values.description,
|
|
434
435
|
priority: values.priority ? parsePriority(values.priority) : undefined,
|
|
435
436
|
labels,
|
|
436
437
|
assignees: parseAssignees(values.assignees),
|
|
@@ -496,6 +497,19 @@ function main() {
|
|
|
496
497
|
break;
|
|
497
498
|
}
|
|
498
499
|
|
|
500
|
+
case "mv":
|
|
501
|
+
case "move": {
|
|
502
|
+
const id = resolveId(args[0], "mv");
|
|
503
|
+
const newProject = args[1];
|
|
504
|
+
if (!newProject) error("Project required: tk mv <id> <project>");
|
|
505
|
+
validateProject(newProject);
|
|
506
|
+
const result = storage.moveTask(id, newProject);
|
|
507
|
+
const refMsg =
|
|
508
|
+
result.referencesUpdated > 0 ? `\nUpdated ${result.referencesUpdated} references` : "";
|
|
509
|
+
output(result, green(`Moved: ${result.old_id} → ${result.new_id}${refMsg}`));
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
|
|
499
513
|
case "clean": {
|
|
500
514
|
const config = storage.getConfig();
|
|
501
515
|
const { values } = parseArgs({
|
|
@@ -616,46 +630,8 @@ function main() {
|
|
|
616
630
|
}
|
|
617
631
|
break;
|
|
618
632
|
}
|
|
619
|
-
case "alias": {
|
|
620
|
-
const { values, positionals } = parseArgs({
|
|
621
|
-
args: args.slice(1),
|
|
622
|
-
options: {
|
|
623
|
-
rm: { type: "string" },
|
|
624
|
-
list: { type: "boolean" },
|
|
625
|
-
},
|
|
626
|
-
allowPositionals: true,
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
if (values.rm) {
|
|
630
|
-
const updated = storage.removeAlias(values.rm);
|
|
631
|
-
output(updated, green(`Removed alias: ${values.rm}`));
|
|
632
|
-
} else if (positionals.length >= 2) {
|
|
633
|
-
const alias = positionals[0];
|
|
634
|
-
const path = positionals[1];
|
|
635
|
-
if (!alias || !path || !alias.trim()) {
|
|
636
|
-
error("Alias name and path required: tk config alias <name> <path>");
|
|
637
|
-
}
|
|
638
|
-
const updated = storage.setAlias(alias, path);
|
|
639
|
-
output(updated, green(`Added alias: ${alias} → ${path}`));
|
|
640
|
-
} else {
|
|
641
|
-
// List aliases
|
|
642
|
-
const aliases = config.aliases;
|
|
643
|
-
if (Object.keys(aliases).length === 0) {
|
|
644
|
-
output(
|
|
645
|
-
{ aliases: {} },
|
|
646
|
-
"No aliases defined. Add one with: tk config alias <name> <path>",
|
|
647
|
-
);
|
|
648
|
-
} else {
|
|
649
|
-
const lines = Object.entries(aliases)
|
|
650
|
-
.map(([a, p]) => `${a} → ${p}`)
|
|
651
|
-
.join("\n");
|
|
652
|
-
output({ aliases }, lines);
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
break;
|
|
656
|
-
}
|
|
657
633
|
default:
|
|
658
|
-
error(`Unknown config command: ${subcommand}. Valid: project
|
|
634
|
+
error(`Unknown config command: ${subcommand}. Valid: project.`);
|
|
659
635
|
}
|
|
660
636
|
break;
|
|
661
637
|
}
|
package/src/db/storage.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "fs";
|
|
12
12
|
import { join, resolve, basename } from "path";
|
|
13
13
|
import { getTasksDir, getWorkingDir } from "../lib/root";
|
|
14
|
-
import { isTaskOverdue } from "../lib/time";
|
|
14
|
+
import { isTaskOverdue, daysUntilDue } from "../lib/time";
|
|
15
15
|
import type { Task, Config, Status, Priority, TaskWithMeta, LogEntry } from "../types";
|
|
16
16
|
import { DEFAULT_CONFIG, taskId, parseId, generateRef } from "../types";
|
|
17
17
|
|
|
@@ -77,8 +77,8 @@ const STATUS_ORDER: Record<Status, number> = { active: 0, open: 1, done: 2 };
|
|
|
77
77
|
* 3. If done: completed_at (newest first)
|
|
78
78
|
*/
|
|
79
79
|
export function compareTasks(a: Task, b: Task): number {
|
|
80
|
-
const sA = STATUS_ORDER[a.status]
|
|
81
|
-
const sB = STATUS_ORDER[b.status]
|
|
80
|
+
const sA = STATUS_ORDER[a.status];
|
|
81
|
+
const sB = STATUS_ORDER[b.status];
|
|
82
82
|
if (sA !== sB) return sA - sB;
|
|
83
83
|
|
|
84
84
|
if (a.status !== "done") {
|
|
@@ -119,7 +119,12 @@ export function getConfig(): Config {
|
|
|
119
119
|
}
|
|
120
120
|
try {
|
|
121
121
|
const text = readFileSync(configPath, "utf-8");
|
|
122
|
-
|
|
122
|
+
const parsed = JSON.parse(text);
|
|
123
|
+
return {
|
|
124
|
+
...DEFAULT_CONFIG,
|
|
125
|
+
...parsed,
|
|
126
|
+
defaults: { ...DEFAULT_CONFIG.defaults, ...parsed.defaults },
|
|
127
|
+
};
|
|
123
128
|
} catch {
|
|
124
129
|
return { ...DEFAULT_CONFIG };
|
|
125
130
|
}
|
|
@@ -234,18 +239,75 @@ export function renameProject(oldProject: string, newProject: string): RenameRes
|
|
|
234
239
|
};
|
|
235
240
|
}
|
|
236
241
|
|
|
237
|
-
export
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
return config;
|
|
242
|
+
export interface MoveResult {
|
|
243
|
+
old_id: string;
|
|
244
|
+
new_id: string;
|
|
245
|
+
referencesUpdated: number;
|
|
242
246
|
}
|
|
243
247
|
|
|
244
|
-
export function
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
248
|
+
export function moveTask(oldId: string, newProject: string): MoveResult {
|
|
249
|
+
const tasksDir = getTasksDir();
|
|
250
|
+
if (!existsSync(tasksDir)) {
|
|
251
|
+
throw new Error("No .tasks/ directory found");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const parsed = parseId(oldId);
|
|
255
|
+
if (!parsed) {
|
|
256
|
+
throw new Error(`Invalid task ID: ${oldId}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (parsed.project === newProject) {
|
|
260
|
+
throw new Error(`Task ${oldId} is already in project "${newProject}"`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const oldPath = getTaskPath(tasksDir, oldId);
|
|
264
|
+
if (!existsSync(oldPath)) {
|
|
265
|
+
throw new Error(`Task not found: ${oldId}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const newId = `${newProject}-${parsed.ref}`;
|
|
269
|
+
const newPath = getTaskPath(tasksDir, newId);
|
|
270
|
+
if (existsSync(newPath)) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`Cannot move: "${newId}" already exists. The ref conflicts with an existing task.`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const task = readTaskFile(oldPath, tasksDir);
|
|
277
|
+
if (!task) {
|
|
278
|
+
throw new Error(`Failed to read task: ${oldId}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Write new file with updated project
|
|
282
|
+
task.project = newProject;
|
|
283
|
+
task.updated_at = new Date().toISOString();
|
|
284
|
+
atomicWrite(newPath, JSON.stringify(task, null, 2));
|
|
285
|
+
unlinkSync(oldPath);
|
|
286
|
+
|
|
287
|
+
// Update all blocked_by and parent refs in other tasks
|
|
288
|
+
let referencesUpdated = 0;
|
|
289
|
+
const allTasks = getAllTasks(tasksDir);
|
|
290
|
+
for (const other of allTasks) {
|
|
291
|
+
let modified = false;
|
|
292
|
+
|
|
293
|
+
if (other.blocked_by.includes(oldId)) {
|
|
294
|
+
other.blocked_by = other.blocked_by.map((id) => (id === oldId ? newId : id));
|
|
295
|
+
referencesUpdated++;
|
|
296
|
+
modified = true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (other.parent === oldId) {
|
|
300
|
+
other.parent = newId;
|
|
301
|
+
referencesUpdated++;
|
|
302
|
+
modified = true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (modified) {
|
|
306
|
+
atomicWrite(getTaskPath(tasksDir, taskId(other)), JSON.stringify(other, null, 2));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { old_id: oldId, new_id: newId, referencesUpdated };
|
|
249
311
|
}
|
|
250
312
|
|
|
251
313
|
// --- Task File Operations ---
|
|
@@ -282,7 +344,8 @@ function isValidTaskStructure(obj: unknown): obj is Task {
|
|
|
282
344
|
typeof t.ref === "string" &&
|
|
283
345
|
typeof t.title === "string" &&
|
|
284
346
|
typeof t.status === "string" &&
|
|
285
|
-
Array.isArray(t.blocked_by)
|
|
347
|
+
Array.isArray(t.blocked_by) &&
|
|
348
|
+
Array.isArray(t.logs)
|
|
286
349
|
);
|
|
287
350
|
}
|
|
288
351
|
|
|
@@ -513,6 +576,7 @@ export function enrichTask(task: Task, statusMap?: Map<string, Status>): TaskWit
|
|
|
513
576
|
id: taskId(task),
|
|
514
577
|
is_overdue: isTaskOverdue(task.due_date, task.status),
|
|
515
578
|
blocked_by_incomplete: blockedByIncomplete,
|
|
579
|
+
days_until_due: daysUntilDue(task.due_date, task.status),
|
|
516
580
|
};
|
|
517
581
|
}
|
|
518
582
|
|
|
@@ -594,9 +658,6 @@ export function getTask(id: string): TaskResult | null {
|
|
|
594
658
|
};
|
|
595
659
|
}
|
|
596
660
|
|
|
597
|
-
// Deprecated alias for getTask
|
|
598
|
-
export const getTaskWithMeta = getTask;
|
|
599
|
-
|
|
600
661
|
export interface ListOptions {
|
|
601
662
|
status?: Status;
|
|
602
663
|
priority?: Priority;
|
|
@@ -685,7 +746,7 @@ export function updateTaskStatus(id: string, status: Status): (Task & { id: stri
|
|
|
685
746
|
|
|
686
747
|
export interface UpdateTaskOptions {
|
|
687
748
|
title?: string;
|
|
688
|
-
description?: string;
|
|
749
|
+
description?: string | null;
|
|
689
750
|
priority?: Priority;
|
|
690
751
|
labels?: string[];
|
|
691
752
|
assignees?: string[];
|
|
@@ -884,38 +945,13 @@ export function resolveId(input: string): string | null {
|
|
|
884
945
|
const tasksDir = getTasksDir();
|
|
885
946
|
if (!existsSync(tasksDir)) return null;
|
|
886
947
|
|
|
887
|
-
//
|
|
888
|
-
if (parseId(input)) {
|
|
889
|
-
|
|
890
|
-
return input;
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
const inputLower = input.toLowerCase();
|
|
895
|
-
|
|
896
|
-
// Read filenames only (no file content parsing)
|
|
897
|
-
const files = readdirSync(tasksDir);
|
|
898
|
-
const matches: string[] = [];
|
|
899
|
-
|
|
900
|
-
for (const file of files) {
|
|
901
|
-
if (!file.endsWith(".json") || file === "config.json") continue;
|
|
902
|
-
|
|
903
|
-
// Extract ID from filename: "project-ref.json" -> "project-ref"
|
|
904
|
-
const id = file.slice(0, -5);
|
|
905
|
-
if (!parseId(id)) continue;
|
|
906
|
-
|
|
907
|
-
// Match by full ID prefix or just ref prefix
|
|
908
|
-
const ref = id.split("-")[1] ?? "";
|
|
909
|
-
if (id.startsWith(inputLower) || ref.startsWith(inputLower)) {
|
|
910
|
-
matches.push(id);
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
if (matches.length === 1 && matches[0]) {
|
|
915
|
-
return matches[0];
|
|
948
|
+
// Fast path: full valid ID that exists
|
|
949
|
+
if (parseId(input) && existsSync(getTaskPath(tasksDir, input))) {
|
|
950
|
+
return input;
|
|
916
951
|
}
|
|
917
952
|
|
|
918
|
-
|
|
953
|
+
const matches = findMatchingIds(input);
|
|
954
|
+
return matches.length === 1 ? (matches[0] ?? null) : null;
|
|
919
955
|
}
|
|
920
956
|
|
|
921
957
|
export function findMatchingIds(input: string): string[] {
|
package/src/lib/completions.ts
CHANGED
|
@@ -12,7 +12,7 @@ _tk() {
|
|
|
12
12
|
local cur prev words cword
|
|
13
13
|
_init_completion || return
|
|
14
14
|
|
|
15
|
-
local commands="init add ls list ready show start done reopen edit log block unblock rm remove clean check config completions help"
|
|
15
|
+
local commands="init add ls list ready show start done reopen edit log block unblock mv move rm remove clean check config completions help"
|
|
16
16
|
local global_opts="--json --help -h --version -V"
|
|
17
17
|
|
|
18
18
|
# Find the command (first non-option word after 'tk')
|
|
@@ -60,6 +60,13 @@ _tk() {
|
|
|
60
60
|
COMPREPLY=($(compgen -W "$(_tk_task_ids)" -- "$cur"))
|
|
61
61
|
fi
|
|
62
62
|
;;
|
|
63
|
+
mv|move)
|
|
64
|
+
if [[ "$cur" == -* ]]; then
|
|
65
|
+
COMPREPLY=($(compgen -W "--json" -- "$cur"))
|
|
66
|
+
else
|
|
67
|
+
COMPREPLY=($(compgen -W "$(_tk_task_ids)" -- "$cur"))
|
|
68
|
+
fi
|
|
69
|
+
;;
|
|
63
70
|
edit)
|
|
64
71
|
if [[ "$cur" == -* ]]; then
|
|
65
72
|
COMPREPLY=($(compgen -W "-t --title -d --description -p --priority -l --labels -A --assignees --parent --estimate --due --json" -- "$cur"))
|
|
@@ -90,9 +97,7 @@ _tk() {
|
|
|
90
97
|
;;
|
|
91
98
|
config)
|
|
92
99
|
if [[ "$prev" == "config" ]]; then
|
|
93
|
-
COMPREPLY=($(compgen -W "project
|
|
94
|
-
elif [[ "$prev" == "alias" ]]; then
|
|
95
|
-
COMPREPLY=($(compgen -W "--rm" -- "$cur"))
|
|
100
|
+
COMPREPLY=($(compgen -W "project" -- "$cur"))
|
|
96
101
|
fi
|
|
97
102
|
;;
|
|
98
103
|
init)
|
|
@@ -139,6 +144,8 @@ _tk() {
|
|
|
139
144
|
'log:Add log entry'
|
|
140
145
|
'block:Add blocker'
|
|
141
146
|
'unblock:Remove blocker'
|
|
147
|
+
'mv:Move task to different project'
|
|
148
|
+
'move:Move task to different project'
|
|
142
149
|
'rm:Delete task'
|
|
143
150
|
'remove:Delete task'
|
|
144
151
|
'clean:Remove old done tasks'
|
|
@@ -207,6 +214,12 @@ _tk() {
|
|
|
207
214
|
'--json[Output as JSON]' \
|
|
208
215
|
'1:task id:_tk_task_ids'
|
|
209
216
|
;;
|
|
217
|
+
mv|move)
|
|
218
|
+
_arguments \
|
|
219
|
+
'--json[Output as JSON]' \
|
|
220
|
+
'1:task id:_tk_task_ids' \
|
|
221
|
+
'2:project:'
|
|
222
|
+
;;
|
|
210
223
|
edit)
|
|
211
224
|
_arguments \
|
|
212
225
|
'(-t --title)'{-t,--title}'[Title]:title:' \
|
|
@@ -244,8 +257,7 @@ _tk() {
|
|
|
244
257
|
;;
|
|
245
258
|
config)
|
|
246
259
|
_arguments \
|
|
247
|
-
'
|
|
248
|
-
'1:subcommand:(project alias)'
|
|
260
|
+
'1:subcommand:(project)'
|
|
249
261
|
;;
|
|
250
262
|
init)
|
|
251
263
|
_arguments \
|
|
@@ -332,6 +344,8 @@ complete -c tk -n __tk_needs_command -f -a edit -d 'Edit task'
|
|
|
332
344
|
complete -c tk -n __tk_needs_command -f -a log -d 'Add log entry'
|
|
333
345
|
complete -c tk -n __tk_needs_command -f -a block -d 'Add blocker'
|
|
334
346
|
complete -c tk -n __tk_needs_command -f -a unblock -d 'Remove blocker'
|
|
347
|
+
complete -c tk -n __tk_needs_command -f -a mv -d 'Move task to different project'
|
|
348
|
+
complete -c tk -n __tk_needs_command -f -a move -d 'Move task to different project'
|
|
335
349
|
complete -c tk -n __tk_needs_command -f -a rm -d 'Delete task'
|
|
336
350
|
complete -c tk -n __tk_needs_command -f -a remove -d 'Delete task'
|
|
337
351
|
complete -c tk -n __tk_needs_command -f -a clean -d 'Remove old done tasks'
|
|
@@ -380,6 +394,8 @@ complete -c tk -n '__tk_using_command show' -f -a '(__tk_task_ids)' -d 'Task ID'
|
|
|
380
394
|
complete -c tk -n '__tk_using_command start' -f -a '(__tk_task_ids)' -d 'Task ID'
|
|
381
395
|
complete -c tk -n '__tk_using_command done' -f -a '(__tk_task_ids)' -d 'Task ID'
|
|
382
396
|
complete -c tk -n '__tk_using_command reopen' -f -a '(__tk_task_ids)' -d 'Task ID'
|
|
397
|
+
complete -c tk -n '__tk_using_command mv' -f -a '(__tk_task_ids)' -d 'Task ID'
|
|
398
|
+
complete -c tk -n '__tk_using_command move' -f -a '(__tk_task_ids)' -d 'Task ID'
|
|
383
399
|
complete -c tk -n '__tk_using_command rm' -f -a '(__tk_task_ids)' -d 'Task ID'
|
|
384
400
|
complete -c tk -n '__tk_using_command remove' -f -a '(__tk_task_ids)' -d 'Task ID'
|
|
385
401
|
complete -c tk -n '__tk_using_command log' -f -a '(__tk_task_ids)' -d 'Task ID'
|
|
@@ -414,8 +430,7 @@ complete -c tk -n '__tk_using_command clean' -l json -d 'Output as JSON'
|
|
|
414
430
|
complete -c tk -n '__tk_using_command check' -l json -d 'Output as JSON'
|
|
415
431
|
|
|
416
432
|
# config command
|
|
417
|
-
complete -c tk -n '__tk_using_command config' -f -a 'project
|
|
418
|
-
complete -c tk -n '__tk_using_command config' -l rm -d 'Remove alias'
|
|
433
|
+
complete -c tk -n '__tk_using_command config' -f -a 'project' -d 'Config option'
|
|
419
434
|
|
|
420
435
|
# init command
|
|
421
436
|
complete -c tk -n '__tk_using_command init' -s P -l project -d 'Project'
|
package/src/lib/format.test.ts
CHANGED
|
@@ -33,7 +33,8 @@ describe("shouldUseColor", () => {
|
|
|
33
33
|
|
|
34
34
|
test("respects NO_COLOR empty string as color enabled", () => {
|
|
35
35
|
process.env.NO_COLOR = "";
|
|
36
|
-
//
|
|
36
|
+
// NO_COLOR="" is treated as unset — color depends on TTY (always false in tests)
|
|
37
|
+
expect(shouldUseColor()).toBe(false);
|
|
37
38
|
});
|
|
38
39
|
});
|
|
39
40
|
|
|
@@ -59,6 +60,7 @@ describe("formatTaskRow", () => {
|
|
|
59
60
|
external: {},
|
|
60
61
|
blocked_by_incomplete: false,
|
|
61
62
|
is_overdue: false,
|
|
63
|
+
days_until_due: null,
|
|
62
64
|
};
|
|
63
65
|
|
|
64
66
|
test("formats task without color", () => {
|
|
@@ -127,6 +129,72 @@ describe("formatTaskRow", () => {
|
|
|
127
129
|
const result = formatTaskRow(todayTask, false);
|
|
128
130
|
expect(result).not.toContain("[OVERDUE]");
|
|
129
131
|
});
|
|
132
|
+
|
|
133
|
+
test("shows [due today] for tasks due today", () => {
|
|
134
|
+
const today = new Date().toISOString().split("T")[0] ?? null;
|
|
135
|
+
const todayTask: TaskWithMeta = {
|
|
136
|
+
...task,
|
|
137
|
+
due_date: today,
|
|
138
|
+
is_overdue: false,
|
|
139
|
+
days_until_due: 0,
|
|
140
|
+
};
|
|
141
|
+
const result = formatTaskRow(todayTask, false);
|
|
142
|
+
expect(result).toContain("[due today]");
|
|
143
|
+
expect(result).not.toContain("[OVERDUE]");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("shows [due Nd] for tasks due in N days within threshold", () => {
|
|
147
|
+
const future = new Date();
|
|
148
|
+
future.setDate(future.getDate() + 3);
|
|
149
|
+
const dateStr = future.toISOString().split("T")[0] ?? null;
|
|
150
|
+
const soonTask: TaskWithMeta = {
|
|
151
|
+
...task,
|
|
152
|
+
due_date: dateStr,
|
|
153
|
+
is_overdue: false,
|
|
154
|
+
days_until_due: 3,
|
|
155
|
+
};
|
|
156
|
+
const result = formatTaskRow(soonTask, false);
|
|
157
|
+
expect(result).toContain("[due 3d]");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("shows no due-soon marker at 8 days", () => {
|
|
161
|
+
const future = new Date();
|
|
162
|
+
future.setDate(future.getDate() + 8);
|
|
163
|
+
const dateStr = future.toISOString().split("T")[0] ?? null;
|
|
164
|
+
const notSoonTask: TaskWithMeta = {
|
|
165
|
+
...task,
|
|
166
|
+
due_date: dateStr,
|
|
167
|
+
is_overdue: false,
|
|
168
|
+
days_until_due: 8,
|
|
169
|
+
};
|
|
170
|
+
const result = formatTaskRow(notSoonTask, false);
|
|
171
|
+
expect(result).not.toContain("[due");
|
|
172
|
+
expect(result).not.toContain("[OVERDUE]");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("shows no due-soon marker when overdue", () => {
|
|
176
|
+
const overdueWithSoon: TaskWithMeta = {
|
|
177
|
+
...task,
|
|
178
|
+
due_date: "2020-01-01",
|
|
179
|
+
is_overdue: true,
|
|
180
|
+
days_until_due: null,
|
|
181
|
+
};
|
|
182
|
+
const result = formatTaskRow(overdueWithSoon, false);
|
|
183
|
+
expect(result).toContain("[OVERDUE]");
|
|
184
|
+
expect(result).not.toContain("[due today]");
|
|
185
|
+
expect(result).not.toContain("[due ");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("shows no due-soon marker for done tasks", () => {
|
|
189
|
+
const doneTask: TaskWithMeta = {
|
|
190
|
+
...task,
|
|
191
|
+
status: "done",
|
|
192
|
+
days_until_due: null,
|
|
193
|
+
completed_at: new Date().toISOString(),
|
|
194
|
+
};
|
|
195
|
+
const result = formatTaskRow(doneTask, false);
|
|
196
|
+
expect(result).not.toContain("[due");
|
|
197
|
+
});
|
|
130
198
|
});
|
|
131
199
|
|
|
132
200
|
describe("formatTaskList", () => {
|
|
@@ -161,6 +229,7 @@ describe("formatTaskList", () => {
|
|
|
161
229
|
external: {},
|
|
162
230
|
blocked_by_incomplete: false,
|
|
163
231
|
is_overdue: false,
|
|
232
|
+
days_until_due: null,
|
|
164
233
|
},
|
|
165
234
|
];
|
|
166
235
|
const result = formatTaskList(tasks, false);
|
|
@@ -194,6 +263,7 @@ describe("formatTaskList", () => {
|
|
|
194
263
|
external: {},
|
|
195
264
|
blocked_by_incomplete: false,
|
|
196
265
|
is_overdue: false,
|
|
266
|
+
days_until_due: null,
|
|
197
267
|
},
|
|
198
268
|
{
|
|
199
269
|
id: "tk-c3d4",
|
|
@@ -216,6 +286,7 @@ describe("formatTaskList", () => {
|
|
|
216
286
|
external: {},
|
|
217
287
|
blocked_by_incomplete: false,
|
|
218
288
|
is_overdue: false,
|
|
289
|
+
days_until_due: null,
|
|
219
290
|
},
|
|
220
291
|
];
|
|
221
292
|
const result = formatTaskList(tasks, false);
|
|
@@ -248,6 +319,7 @@ describe("formatTaskDetail", () => {
|
|
|
248
319
|
external: {},
|
|
249
320
|
blocked_by_incomplete: true,
|
|
250
321
|
is_overdue: false,
|
|
322
|
+
days_until_due: null,
|
|
251
323
|
};
|
|
252
324
|
|
|
253
325
|
test("includes all task fields", () => {
|
package/src/lib/format.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { TaskWithMeta, LogEntry } from "../types";
|
|
2
2
|
import { PRIORITY_COLORS, STATUS_COLORS, OVERDUE_COLOR, RESET } from "../types";
|
|
3
3
|
import { formatPriority } from "./priority";
|
|
4
|
-
import { formatDate, formatRelativeTime } from "./time";
|
|
4
|
+
import { formatDate, formatRelativeTime, DUE_SOON_THRESHOLD } from "./time";
|
|
5
|
+
|
|
6
|
+
const DUE_SOON_COLOR = "\x1b[33m"; // yellow
|
|
5
7
|
|
|
6
8
|
// Message colors
|
|
7
9
|
const GREEN = "\x1b[32m";
|
|
@@ -10,17 +12,14 @@ const YELLOW = "\x1b[33m";
|
|
|
10
12
|
const DIM = "\x1b[2m";
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
|
-
* Determines if color output should be used.
|
|
15
|
+
* Determines if color output should be used for the given stream.
|
|
14
16
|
* Respects NO_COLOR env var (https://no-color.org/) and TTY detection.
|
|
15
17
|
*/
|
|
16
|
-
export function shouldUseColor(): boolean {
|
|
18
|
+
export function shouldUseColor(stream: NodeJS.WriteStream = process.stdout): boolean {
|
|
17
19
|
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") {
|
|
18
20
|
return false;
|
|
19
21
|
}
|
|
20
|
-
|
|
21
|
-
return false;
|
|
22
|
-
}
|
|
23
|
-
return true;
|
|
22
|
+
return stream.isTTY ?? false;
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
/** Format text green (success) */
|
|
@@ -28,9 +27,9 @@ export function green(msg: string): string {
|
|
|
28
27
|
return shouldUseColor() ? `${GREEN}${msg}${RESET}` : msg;
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
/** Format text red (error) */
|
|
30
|
+
/** Format text red (error) — checks stderr since errors are written there */
|
|
32
31
|
export function red(msg: string): string {
|
|
33
|
-
return shouldUseColor() ? `${RED}${msg}${RESET}` : msg;
|
|
32
|
+
return shouldUseColor(process.stderr) ? `${RED}${msg}${RESET}` : msg;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
/** Format text yellow (warning) */
|
|
@@ -64,8 +63,9 @@ export function formatTaskRow(task: TaskWithMeta, useColor?: boolean): string {
|
|
|
64
63
|
const color = useColor ?? shouldUseColor();
|
|
65
64
|
const id = formatId(task.id).padEnd(11);
|
|
66
65
|
const priority = formatPriority(task.priority).padEnd(4);
|
|
67
|
-
const title = truncate(task.title, 50);
|
|
68
66
|
const isOverdue = task.is_overdue;
|
|
67
|
+
const dueSoon =
|
|
68
|
+
!isOverdue && task.days_until_due !== null && task.days_until_due <= DUE_SOON_THRESHOLD;
|
|
69
69
|
|
|
70
70
|
let statusText: string = task.status;
|
|
71
71
|
if (task.status === "done" && task.completed_at) {
|
|
@@ -75,13 +75,19 @@ export function formatTaskRow(task: TaskWithMeta, useColor?: boolean): string {
|
|
|
75
75
|
|
|
76
76
|
if (color) {
|
|
77
77
|
const pc = PRIORITY_COLORS[task.priority];
|
|
78
|
-
const sc = isOverdue ? OVERDUE_COLOR : STATUS_COLORS[task.status];
|
|
78
|
+
const sc = isOverdue ? OVERDUE_COLOR : dueSoon ? DUE_SOON_COLOR : STATUS_COLORS[task.status];
|
|
79
79
|
const tc = task.status === "done" ? DIM : "";
|
|
80
|
+
const title = truncate(task.title, 50);
|
|
80
81
|
return `${id} | ${pc}${priority}${RESET} | ${sc}${status}${RESET} | ${tc}${title}${RESET}`;
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
let dueSoonMarker = "";
|
|
85
|
+
if (dueSoon) {
|
|
86
|
+
dueSoonMarker = task.days_until_due === 0 ? " [due today]" : ` [due ${task.days_until_due}d]`;
|
|
87
|
+
}
|
|
83
88
|
const overdueMarker = isOverdue ? " [OVERDUE]" : "";
|
|
84
|
-
|
|
89
|
+
const title = truncate(task.title, 50);
|
|
90
|
+
return `${id} | ${priority} | ${status} | ${title}${overdueMarker}${dueSoonMarker}`;
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
export function formatTaskList(
|
|
@@ -136,8 +142,16 @@ export function formatTaskDetail(task: TaskWithMeta, logs: LogEntry[], useColor?
|
|
|
136
142
|
}
|
|
137
143
|
|
|
138
144
|
if (task.due_date) {
|
|
145
|
+
const yc = color ? DUE_SOON_COLOR : "";
|
|
146
|
+
const dueSoon =
|
|
147
|
+
!task.is_overdue && task.days_until_due !== null && task.days_until_due <= DUE_SOON_THRESHOLD;
|
|
148
|
+
let dueSoonStr = "";
|
|
149
|
+
if (dueSoon) {
|
|
150
|
+
const marker = task.days_until_due === 0 ? "[due today]" : `[due ${task.days_until_due}d]`;
|
|
151
|
+
dueSoonStr = ` ${yc}${marker}${r}`;
|
|
152
|
+
}
|
|
139
153
|
const overdueStr = task.is_overdue ? ` ${oc}[OVERDUE]${r}` : "";
|
|
140
|
-
lines.push(`Due: ${task.due_date}${overdueStr}`);
|
|
154
|
+
lines.push(`Due: ${task.due_date}${overdueStr}${dueSoonStr}`);
|
|
141
155
|
}
|
|
142
156
|
|
|
143
157
|
lines.push(`Created: ${formatDate(task.created_at)}`);
|
|
@@ -167,22 +181,9 @@ export function formatJson(data: unknown): string {
|
|
|
167
181
|
return JSON.stringify(data, null, 2);
|
|
168
182
|
}
|
|
169
183
|
|
|
170
|
-
export function formatConfig(config: {
|
|
171
|
-
version: number;
|
|
172
|
-
project: string;
|
|
173
|
-
aliases: Record<string, string>;
|
|
174
|
-
}): string {
|
|
184
|
+
export function formatConfig(config: { version: number; project: string }): string {
|
|
175
185
|
const lines: string[] = [];
|
|
176
186
|
lines.push(`Version: ${config.version}`);
|
|
177
187
|
lines.push(`Project: ${config.project}`);
|
|
178
|
-
|
|
179
|
-
if (Object.keys(config.aliases).length > 0) {
|
|
180
|
-
lines.push("");
|
|
181
|
-
lines.push("Aliases:");
|
|
182
|
-
for (const [alias, path] of Object.entries(config.aliases)) {
|
|
183
|
-
lines.push(` ${alias} → ${path}`);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
188
|
return lines.join("\n");
|
|
188
189
|
}
|
package/src/lib/help.ts
CHANGED
|
@@ -18,6 +18,7 @@ COMMANDS:
|
|
|
18
18
|
log Add log entry
|
|
19
19
|
block Add blocker
|
|
20
20
|
unblock Remove blocker
|
|
21
|
+
mv, move Move task to different project
|
|
21
22
|
rm, remove Delete task
|
|
22
23
|
clean Remove old done tasks
|
|
23
24
|
check Check for data issues
|
|
@@ -173,6 +174,26 @@ USAGE:
|
|
|
173
174
|
|
|
174
175
|
EXAMPLES:
|
|
175
176
|
tk unblock tk-2 tk-1
|
|
177
|
+
`,
|
|
178
|
+
mv: `tk mv - Move a task to a different project
|
|
179
|
+
|
|
180
|
+
USAGE:
|
|
181
|
+
tk mv <id> <project>
|
|
182
|
+
|
|
183
|
+
Moves a task to a new project, keeping the same ref.
|
|
184
|
+
Errors if the new ID already exists.
|
|
185
|
+
Updates all blocked_by and parent references.
|
|
186
|
+
|
|
187
|
+
EXAMPLES:
|
|
188
|
+
tk mv api-a1b2 web # Moves api-a1b2 → web-a1b2
|
|
189
|
+
tk mv a1b2 archive # Partial ID resolution
|
|
190
|
+
`,
|
|
191
|
+
move: `tk move - Move a task to a different project (alias for 'mv')
|
|
192
|
+
|
|
193
|
+
USAGE:
|
|
194
|
+
tk move <id> <project>
|
|
195
|
+
|
|
196
|
+
Run 'tk mv --help' for options.
|
|
176
197
|
`,
|
|
177
198
|
rm: `tk rm - Delete a task
|
|
178
199
|
|
|
@@ -223,14 +244,10 @@ USAGE:
|
|
|
223
244
|
tk config project Show default project
|
|
224
245
|
tk config project <name> Set default project
|
|
225
246
|
tk config project <new> --rename <old> Rename project (old-* → new-*)
|
|
226
|
-
tk config alias List aliases
|
|
227
|
-
tk config alias <name> <path> Add alias
|
|
228
|
-
tk config alias --rm <name> Remove alias
|
|
229
247
|
|
|
230
248
|
EXAMPLES:
|
|
231
249
|
tk config project api
|
|
232
250
|
tk config project lsmvec --rename cloudlsmvec
|
|
233
|
-
tk config alias web src/web
|
|
234
251
|
`,
|
|
235
252
|
completions: `tk completions - Output shell completions
|
|
236
253
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isTaskOverdue,
|
|
4
|
+
daysUntilDue,
|
|
5
|
+
formatRelativeTime,
|
|
6
|
+
formatDate,
|
|
7
|
+
formatLocalDate,
|
|
8
|
+
parseDueDate,
|
|
9
|
+
parseEstimate,
|
|
10
|
+
DUE_SOON_THRESHOLD,
|
|
11
|
+
} from "./time";
|
|
12
|
+
|
|
13
|
+
describe("isTaskOverdue", () => {
|
|
14
|
+
test("returns false when no due date", () => {
|
|
15
|
+
expect(isTaskOverdue(null, "open")).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("returns false for done tasks regardless of date", () => {
|
|
19
|
+
expect(isTaskOverdue("2020-01-01", "done")).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("returns false for active done tasks", () => {
|
|
23
|
+
expect(isTaskOverdue("2020-01-01", "done")).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("returns true for past dates (open)", () => {
|
|
27
|
+
expect(isTaskOverdue("2020-01-01", "open")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("returns true for past dates (active)", () => {
|
|
31
|
+
expect(isTaskOverdue("2020-06-15", "active")).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("returns false for today", () => {
|
|
35
|
+
const today = new Date().toISOString().split("T")[0]!;
|
|
36
|
+
expect(isTaskOverdue(today, "open")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns false for future dates", () => {
|
|
40
|
+
const future = new Date();
|
|
41
|
+
future.setDate(future.getDate() + 7);
|
|
42
|
+
const dateStr = future.toISOString().split("T")[0]!;
|
|
43
|
+
expect(isTaskOverdue(dateStr, "open")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("returns false for invalid date string", () => {
|
|
47
|
+
expect(isTaskOverdue("not-a-date", "open")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("daysUntilDue", () => {
|
|
52
|
+
test("returns null when no due date", () => {
|
|
53
|
+
expect(daysUntilDue(null, "open")).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("returns null when no due date (active)", () => {
|
|
57
|
+
expect(daysUntilDue(null, "active")).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("returns null for done tasks", () => {
|
|
61
|
+
const future = new Date();
|
|
62
|
+
future.setDate(future.getDate() + 3);
|
|
63
|
+
const dateStr = future.toISOString().split("T")[0]!;
|
|
64
|
+
expect(daysUntilDue(dateStr, "done")).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("returns 0 for due today (open)", () => {
|
|
68
|
+
const today = new Date().toISOString().split("T")[0]!;
|
|
69
|
+
expect(daysUntilDue(today, "open")).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("returns 0 for due today (active)", () => {
|
|
73
|
+
const today = new Date().toISOString().split("T")[0]!;
|
|
74
|
+
expect(daysUntilDue(today, "active")).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns N for due in N days", () => {
|
|
78
|
+
const future = new Date();
|
|
79
|
+
future.setDate(future.getDate() + 3);
|
|
80
|
+
const dateStr = future.toISOString().split("T")[0]!;
|
|
81
|
+
expect(daysUntilDue(dateStr, "open")).toBe(3);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("returns null for past dates (already overdue)", () => {
|
|
85
|
+
expect(daysUntilDue("2020-01-01", "open")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("returns null for invalid date string", () => {
|
|
89
|
+
expect(daysUntilDue("not-a-date", "open")).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("DUE_SOON_THRESHOLD is 7", () => {
|
|
93
|
+
expect(DUE_SOON_THRESHOLD).toBe(7);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("formatRelativeTime", () => {
|
|
98
|
+
test("returns 'now' for very recent timestamps", () => {
|
|
99
|
+
const ts = new Date().toISOString();
|
|
100
|
+
expect(formatRelativeTime(ts)).toBe("now");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("returns minutes for timestamps minutes ago", () => {
|
|
104
|
+
const ts = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
|
105
|
+
expect(formatRelativeTime(ts)).toBe("5m");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("returns hours for timestamps hours ago", () => {
|
|
109
|
+
const ts = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
|
|
110
|
+
expect(formatRelativeTime(ts)).toBe("3h");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("returns days for timestamps days ago", () => {
|
|
114
|
+
const ts = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
|
|
115
|
+
expect(formatRelativeTime(ts)).toBe("2d");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("formatDate", () => {
|
|
120
|
+
test("returns a non-empty string for a valid ISO timestamp", () => {
|
|
121
|
+
const result = formatDate("2024-01-15T10:30:00.000Z");
|
|
122
|
+
expect(typeof result).toBe("string");
|
|
123
|
+
expect(result.length).toBeGreaterThan(0);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("formatLocalDate", () => {
|
|
128
|
+
test("formats date as YYYY-MM-DD", () => {
|
|
129
|
+
const date = new Date(2026, 0, 15); // Jan 15 2026
|
|
130
|
+
expect(formatLocalDate(date)).toBe("2026-01-15");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("zero-pads month and day", () => {
|
|
134
|
+
const date = new Date(2026, 1, 5); // Feb 5 2026
|
|
135
|
+
expect(formatLocalDate(date)).toBe("2026-02-05");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("parseDueDate", () => {
|
|
140
|
+
test("returns undefined for undefined input", () => {
|
|
141
|
+
expect(parseDueDate(undefined)).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("returns undefined for '-' (clear sentinel)", () => {
|
|
145
|
+
expect(parseDueDate("-")).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("returns valid YYYY-MM-DD unchanged", () => {
|
|
149
|
+
expect(parseDueDate("2026-06-15")).toBe("2026-06-15");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("parses +Nd relative date", () => {
|
|
153
|
+
const result = parseDueDate("+7d");
|
|
154
|
+
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
155
|
+
const resultDate = new Date(result!);
|
|
156
|
+
const today = new Date();
|
|
157
|
+
const diffDays = Math.round((resultDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
|
158
|
+
expect(diffDays).toBeGreaterThanOrEqual(6);
|
|
159
|
+
expect(diffDays).toBeLessThanOrEqual(8);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("parses +Nw relative date", () => {
|
|
163
|
+
const result = parseDueDate("+2w");
|
|
164
|
+
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("parses +Nm relative date", () => {
|
|
168
|
+
const result = parseDueDate("+1m");
|
|
169
|
+
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("parses +Nh relative date", () => {
|
|
173
|
+
const result = parseDueDate("+3h");
|
|
174
|
+
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("throws for invalid relative format", () => {
|
|
178
|
+
expect(() => parseDueDate("+7x")).toThrow("Invalid relative date");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("throws for bare + with no content", () => {
|
|
182
|
+
expect(() => parseDueDate("+")).toThrow("Invalid relative date");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("throws for non-date string", () => {
|
|
186
|
+
expect(() => parseDueDate("not-a-date")).toThrow("Invalid date format");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("throws for invalid calendar date (month 13)", () => {
|
|
190
|
+
expect(() => parseDueDate("2026-13-01")).toThrow("Invalid date format");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("throws for invalid calendar date (day 32)", () => {
|
|
194
|
+
expect(() => parseDueDate("2026-01-32")).toThrow("Invalid date format");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("parseEstimate", () => {
|
|
199
|
+
test("returns undefined for undefined input", () => {
|
|
200
|
+
expect(parseEstimate(undefined)).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("parses valid integer string", () => {
|
|
204
|
+
expect(parseEstimate("42")).toBe(42);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("parses zero", () => {
|
|
208
|
+
expect(parseEstimate("0")).toBe(0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("throws for non-numeric string", () => {
|
|
212
|
+
expect(() => parseEstimate("abc")).toThrow("Invalid estimate");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("throws for decimal", () => {
|
|
216
|
+
expect(() => parseEstimate("3.5")).toThrow("Invalid estimate");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("throws for negative number", () => {
|
|
220
|
+
expect(() => parseEstimate("-1")).toThrow("Invalid estimate");
|
|
221
|
+
});
|
|
222
|
+
});
|
package/src/lib/time.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Status } from "../types";
|
|
2
2
|
|
|
3
|
+
export const DUE_SOON_THRESHOLD = 7;
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Determines if a task is overdue.
|
|
5
7
|
* Done tasks are never overdue.
|
|
@@ -12,11 +14,32 @@ export function isTaskOverdue(dueDate: string | null, status: Status): boolean {
|
|
|
12
14
|
const year = parts[0];
|
|
13
15
|
const month = parts[1];
|
|
14
16
|
const day = parts[2];
|
|
15
|
-
if (
|
|
17
|
+
if (year === undefined || !month || !day) return false;
|
|
16
18
|
const due = new Date(year, month - 1, day);
|
|
17
19
|
return due < today;
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Returns the number of days until a task is due (0 = today), or null if:
|
|
24
|
+
* - No due date
|
|
25
|
+
* - Task is done
|
|
26
|
+
* - Task is already overdue
|
|
27
|
+
*/
|
|
28
|
+
export function daysUntilDue(dueDate: string | null, status: Status): number | null {
|
|
29
|
+
if (!dueDate || status === "done") return null;
|
|
30
|
+
const today = new Date();
|
|
31
|
+
today.setHours(0, 0, 0, 0);
|
|
32
|
+
const parts = dueDate.split("-").map(Number);
|
|
33
|
+
const year = parts[0];
|
|
34
|
+
const month = parts[1];
|
|
35
|
+
const day = parts[2];
|
|
36
|
+
if (year === undefined || !month || !day) return null;
|
|
37
|
+
const due = new Date(year, month - 1, day);
|
|
38
|
+
const diffMs = due.getTime() - today.getTime();
|
|
39
|
+
if (diffMs < 0) return null; // already overdue
|
|
40
|
+
return Math.round(diffMs / (1000 * 60 * 60 * 24));
|
|
41
|
+
}
|
|
42
|
+
|
|
20
43
|
/**
|
|
21
44
|
* Format a timestamp into a human-readable relative time (e.g., "2d", "5h").
|
|
22
45
|
*/
|
package/src/types.ts
CHANGED
|
@@ -51,7 +51,8 @@ export function parseId(id: string): { project: string; ref: string } | null {
|
|
|
51
51
|
// Generate random 4-char ref (a-z0-9)
|
|
52
52
|
export function generateRef(): string {
|
|
53
53
|
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; // 36 chars
|
|
54
|
-
const
|
|
54
|
+
const charsetLen = chars.length; // 36
|
|
55
|
+
const maxValid = charsetLen * Math.floor(256 / charsetLen); // 252: reject >= maxValid to avoid modulo bias
|
|
55
56
|
const result: string[] = [];
|
|
56
57
|
|
|
57
58
|
while (result.length < 4) {
|
|
@@ -59,7 +60,7 @@ export function generateRef(): string {
|
|
|
59
60
|
crypto.getRandomValues(bytes);
|
|
60
61
|
for (const b of bytes) {
|
|
61
62
|
if (b < maxValid && result.length < 4) {
|
|
62
|
-
result.push(chars[b %
|
|
63
|
+
result.push(chars[b % charsetLen]!);
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
}
|
|
@@ -70,6 +71,7 @@ export interface TaskWithMeta extends Task {
|
|
|
70
71
|
id: string; // computed from project-ref
|
|
71
72
|
blocked_by_incomplete: boolean;
|
|
72
73
|
is_overdue: boolean;
|
|
74
|
+
days_until_due: number | null;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
export interface Config {
|
|
@@ -80,7 +82,6 @@ export interface Config {
|
|
|
80
82
|
labels: string[];
|
|
81
83
|
assignees: string[];
|
|
82
84
|
};
|
|
83
|
-
aliases: Record<string, string>; // alias -> path for auto-project detection
|
|
84
85
|
clean_after: number | false; // days to keep done tasks, or false to disable
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -92,7 +93,6 @@ export const DEFAULT_CONFIG: Config = {
|
|
|
92
93
|
labels: [],
|
|
93
94
|
assignees: [],
|
|
94
95
|
},
|
|
95
|
-
aliases: {},
|
|
96
96
|
clean_after: 14,
|
|
97
97
|
};
|
|
98
98
|
|