kanbango 3.0.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -0
- package/CHANGELOG.md +18 -0
- package/README.md +18 -3
- package/agent-playbook.js +13 -5
- package/bin/kanban.js +163 -20
- package/index.html +61 -31
- package/kanban.js +525 -7
- package/mcp-server.js +119 -16
- package/package.json +1 -1
- package/tests/run.js +1 -0
package/kanban.js
CHANGED
|
@@ -2,6 +2,7 @@ const fs = require('fs').promises;
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
|
|
4
4
|
const BACKLOG = path.join(process.cwd(), 'backlog');
|
|
5
|
+
const EPICS_DIR = path.join(BACKLOG, 'epics');
|
|
5
6
|
const COLS = ['active', 'planned', 'icebox', 'done'];
|
|
6
7
|
const STATUS_MAP = {
|
|
7
8
|
active: 'in_progress',
|
|
@@ -10,11 +11,12 @@ const STATUS_MAP = {
|
|
|
10
11
|
done: 'done'
|
|
11
12
|
};
|
|
12
13
|
const VIEW_FIELDS = {
|
|
13
|
-
summary: ['task_number', 'title', 'column', 'epic_group', 'created', 'progress'],
|
|
14
|
+
summary: ['task_number', 'title', 'column', 'epic_id', 'epic_group', 'created', 'progress'],
|
|
14
15
|
planning: [
|
|
15
16
|
'task_number',
|
|
16
17
|
'title',
|
|
17
18
|
'column',
|
|
19
|
+
'epic_id',
|
|
18
20
|
'epic_group',
|
|
19
21
|
'created',
|
|
20
22
|
'progress',
|
|
@@ -29,6 +31,7 @@ const VIEW_FIELDS = {
|
|
|
29
31
|
'task_number',
|
|
30
32
|
'title',
|
|
31
33
|
'column',
|
|
34
|
+
'epic_id',
|
|
32
35
|
'epic_group',
|
|
33
36
|
'created',
|
|
34
37
|
'progress',
|
|
@@ -44,6 +47,7 @@ const VIEW_FIELDS = {
|
|
|
44
47
|
'task_number',
|
|
45
48
|
'title',
|
|
46
49
|
'column',
|
|
50
|
+
'epic_id',
|
|
47
51
|
'epic_group',
|
|
48
52
|
'created',
|
|
49
53
|
'progress',
|
|
@@ -58,6 +62,34 @@ const VIEW_FIELDS = {
|
|
|
58
62
|
]
|
|
59
63
|
};
|
|
60
64
|
|
|
65
|
+
const EPIC_VIEW_FIELDS = {
|
|
66
|
+
summary: ['id', 'title', 'created', 'status', 'progress'],
|
|
67
|
+
planning: [
|
|
68
|
+
'id',
|
|
69
|
+
'title',
|
|
70
|
+
'created',
|
|
71
|
+
'status',
|
|
72
|
+
'progress',
|
|
73
|
+
'description',
|
|
74
|
+
'goals',
|
|
75
|
+
'in_scope',
|
|
76
|
+
'out_of_scope'
|
|
77
|
+
],
|
|
78
|
+
full: [
|
|
79
|
+
'id',
|
|
80
|
+
'title',
|
|
81
|
+
'created',
|
|
82
|
+
'status',
|
|
83
|
+
'progress',
|
|
84
|
+
'description',
|
|
85
|
+
'goals',
|
|
86
|
+
'in_scope',
|
|
87
|
+
'out_of_scope',
|
|
88
|
+
'notes',
|
|
89
|
+
'tasks'
|
|
90
|
+
]
|
|
91
|
+
};
|
|
92
|
+
|
|
61
93
|
// Hard-required on create: title only (keeps GUI/CLI quick-add usable).
|
|
62
94
|
// Strongly recommended for agent/planned work — missing ones yield warnings, not errors.
|
|
63
95
|
const RECOMMENDED_CREATE_FIELDS = [
|
|
@@ -68,6 +100,13 @@ const RECOMMENDED_CREATE_FIELDS = [
|
|
|
68
100
|
'acceptance_criteria'
|
|
69
101
|
];
|
|
70
102
|
|
|
103
|
+
const RECOMMENDED_EPIC_CREATE_FIELDS = [
|
|
104
|
+
'description',
|
|
105
|
+
'goals',
|
|
106
|
+
'in_scope',
|
|
107
|
+
'out_of_scope'
|
|
108
|
+
];
|
|
109
|
+
|
|
71
110
|
function createKanbanError(code, message, hint, details = {}, retryable = false, status = 400) {
|
|
72
111
|
const error = new Error(message);
|
|
73
112
|
error.code = code;
|
|
@@ -103,7 +142,12 @@ function normalizeStringArray(value) {
|
|
|
103
142
|
}
|
|
104
143
|
|
|
105
144
|
function isPresentCreateField(field, value) {
|
|
106
|
-
if (
|
|
145
|
+
if (
|
|
146
|
+
field === 'description'
|
|
147
|
+
|| field === 'specs'
|
|
148
|
+
|| field === 'notes'
|
|
149
|
+
|| field === 'goals'
|
|
150
|
+
) {
|
|
107
151
|
return Boolean(normalizeString(value));
|
|
108
152
|
}
|
|
109
153
|
if (
|
|
@@ -123,6 +167,10 @@ function missingRecommendedCreateFields(payload = {}) {
|
|
|
123
167
|
return RECOMMENDED_CREATE_FIELDS.filter((field) => !isPresentCreateField(field, payload[field]));
|
|
124
168
|
}
|
|
125
169
|
|
|
170
|
+
function missingRecommendedEpicCreateFields(payload = {}) {
|
|
171
|
+
return RECOMMENDED_EPIC_CREATE_FIELDS.filter((field) => !isPresentCreateField(field, payload[field]));
|
|
172
|
+
}
|
|
173
|
+
|
|
126
174
|
function createFieldWarnings(payload = {}) {
|
|
127
175
|
const missing = missingRecommendedCreateFields(payload);
|
|
128
176
|
if (missing.length === 0) return [];
|
|
@@ -132,6 +180,28 @@ function createFieldWarnings(payload = {}) {
|
|
|
132
180
|
];
|
|
133
181
|
}
|
|
134
182
|
|
|
183
|
+
function createEpicFieldWarnings(payload = {}) {
|
|
184
|
+
const missing = missingRecommendedEpicCreateFields(payload);
|
|
185
|
+
if (missing.length === 0) return [];
|
|
186
|
+
return [
|
|
187
|
+
`Strongly recommended epic fields missing: ${missing.join(', ')}. ` +
|
|
188
|
+
'Fill them so agents get initiative context and boundaries.'
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeEpicId(value) {
|
|
193
|
+
const raw = normalizeString(value);
|
|
194
|
+
if (!raw || raw === '—') return null;
|
|
195
|
+
const match = raw.match(/^E0*(\d+)$/i);
|
|
196
|
+
if (match) return `E${String(parseInt(match[1], 10)).padStart(3, '0')}`;
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isBlankEpicRef(value) {
|
|
201
|
+
const raw = normalizeString(value);
|
|
202
|
+
return !raw || raw === '—';
|
|
203
|
+
}
|
|
204
|
+
|
|
135
205
|
function normalizeSubtasks(value) {
|
|
136
206
|
if (!Array.isArray(value)) return [];
|
|
137
207
|
return value.map((subtask, idx) => ({
|
|
@@ -164,11 +234,14 @@ function normalizePlan(value) {
|
|
|
164
234
|
|
|
165
235
|
function normalizeTask(task) {
|
|
166
236
|
const id = normalizeString(task.id);
|
|
237
|
+
const epicId = normalizeEpicId(task.epic_id);
|
|
238
|
+
const epicGroup = normalizeString(task.epic_group, '—') || '—';
|
|
167
239
|
const normalized = {
|
|
168
240
|
id,
|
|
169
241
|
title: stripTitlePrefix(task.title || id),
|
|
170
242
|
column: COLS.includes(task.column) ? task.column : 'planned',
|
|
171
|
-
|
|
243
|
+
epic_id: epicId,
|
|
244
|
+
epic_group: epicId ? (epicGroup === '—' ? epicId : epicGroup) : (epicGroup === '—' ? '—' : epicGroup),
|
|
172
245
|
created: normalizeString(task.created) || todayIso(),
|
|
173
246
|
description: normalizeString(task.description),
|
|
174
247
|
specs: normalizeString(task.specs),
|
|
@@ -192,6 +265,7 @@ function serializeTask(task) {
|
|
|
192
265
|
id: normalized.id,
|
|
193
266
|
title: normalized.title,
|
|
194
267
|
column: normalized.column,
|
|
268
|
+
epic_id: normalized.epic_id,
|
|
195
269
|
epic_group: normalized.epic_group,
|
|
196
270
|
created: normalized.created,
|
|
197
271
|
description: normalized.description,
|
|
@@ -208,6 +282,83 @@ function serializeTask(task) {
|
|
|
208
282
|
};
|
|
209
283
|
}
|
|
210
284
|
|
|
285
|
+
function normalizeEpic(epic) {
|
|
286
|
+
const id = normalizeEpicId(epic && epic.id) || normalizeString(epic && epic.id);
|
|
287
|
+
return {
|
|
288
|
+
id,
|
|
289
|
+
title: stripTitlePrefix((epic && epic.title) || id),
|
|
290
|
+
created: normalizeString(epic && epic.created) || todayIso(),
|
|
291
|
+
description: normalizeString(epic && epic.description),
|
|
292
|
+
goals: normalizeString(epic && epic.goals),
|
|
293
|
+
in_scope: normalizeStringArray(epic && epic.in_scope),
|
|
294
|
+
out_of_scope: normalizeStringArray(epic && epic.out_of_scope),
|
|
295
|
+
notes: normalizeString(epic && epic.notes)
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function serializeEpic(epic) {
|
|
300
|
+
const normalized = normalizeEpic(epic);
|
|
301
|
+
return {
|
|
302
|
+
id: normalized.id,
|
|
303
|
+
title: normalized.title,
|
|
304
|
+
created: normalized.created,
|
|
305
|
+
description: normalized.description,
|
|
306
|
+
goals: normalized.goals,
|
|
307
|
+
in_scope: normalized.in_scope,
|
|
308
|
+
out_of_scope: normalized.out_of_scope,
|
|
309
|
+
notes: normalized.notes
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function deriveEpicStatus(tasks) {
|
|
314
|
+
if (!tasks || tasks.length === 0) return 'empty';
|
|
315
|
+
if (tasks.some((task) => task.column === 'active')) return 'active';
|
|
316
|
+
if (tasks.every((task) => task.column === 'done')) return 'done';
|
|
317
|
+
return 'planned';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function getEpicProgress(tasks) {
|
|
321
|
+
const progress = {
|
|
322
|
+
tasks_total: tasks.length,
|
|
323
|
+
tasks_done: 0,
|
|
324
|
+
tasks_active: 0,
|
|
325
|
+
tasks_planned: 0,
|
|
326
|
+
tasks_icebox: 0
|
|
327
|
+
};
|
|
328
|
+
for (const task of tasks) {
|
|
329
|
+
if (task.column === 'done') progress.tasks_done += 1;
|
|
330
|
+
else if (task.column === 'active') progress.tasks_active += 1;
|
|
331
|
+
else if (task.column === 'planned') progress.tasks_planned += 1;
|
|
332
|
+
else if (task.column === 'icebox') progress.tasks_icebox += 1;
|
|
333
|
+
}
|
|
334
|
+
return progress;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function pickEpicFields(epicPayload, fieldNames) {
|
|
338
|
+
const picked = {};
|
|
339
|
+
for (const field of fieldNames) {
|
|
340
|
+
if (field in epicPayload) {
|
|
341
|
+
picked[field] = epicPayload[field];
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return picked;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function shapeEpic(epic, tasks = [], options = {}) {
|
|
348
|
+
const normalized = normalizeEpic(epic);
|
|
349
|
+
const childTasks = tasks.filter((task) => task.epic_id === normalized.id);
|
|
350
|
+
const payload = {
|
|
351
|
+
...normalized,
|
|
352
|
+
status: deriveEpicStatus(childTasks),
|
|
353
|
+
progress: getEpicProgress(childTasks),
|
|
354
|
+
tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' }))
|
|
355
|
+
};
|
|
356
|
+
const fields = Array.isArray(options.fields) && options.fields.length > 0
|
|
357
|
+
? options.fields
|
|
358
|
+
: (EPIC_VIEW_FIELDS[options.view || 'full'] || EPIC_VIEW_FIELDS.full);
|
|
359
|
+
return pickEpicFields(payload, fields);
|
|
360
|
+
}
|
|
361
|
+
|
|
211
362
|
function getProgress(task) {
|
|
212
363
|
const total = task.subtasks.length;
|
|
213
364
|
const done = task.subtasks.filter((subtask) => subtask.done).length;
|
|
@@ -269,6 +420,7 @@ async function ensureBacklogDir() {
|
|
|
269
420
|
const colDir = path.join(BACKLOG, col);
|
|
270
421
|
await fs.mkdir(colDir, { recursive: true });
|
|
271
422
|
}
|
|
423
|
+
await fs.mkdir(EPICS_DIR, { recursive: true });
|
|
272
424
|
}
|
|
273
425
|
|
|
274
426
|
async function parseMarkdownTask(filePath, column) {
|
|
@@ -371,6 +523,331 @@ async function allEpics() {
|
|
|
371
523
|
return epics;
|
|
372
524
|
}
|
|
373
525
|
|
|
526
|
+
async function allTasks() {
|
|
527
|
+
return allEpics();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function epicFilePath(epicId) {
|
|
531
|
+
return path.join(EPICS_DIR, `${epicId}.json`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function nextEpicNumber() {
|
|
535
|
+
await ensureBacklogDir();
|
|
536
|
+
const ids = [];
|
|
537
|
+
try {
|
|
538
|
+
const files = await fs.readdir(EPICS_DIR);
|
|
539
|
+
for (const file of files) {
|
|
540
|
+
const match = file.match(/^E0*(\d+)\.json$/i);
|
|
541
|
+
if (match) ids.push(parseInt(match[1], 10));
|
|
542
|
+
}
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (error.code !== 'ENOENT') throw error;
|
|
545
|
+
}
|
|
546
|
+
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function parseJsonEpic(filePath) {
|
|
550
|
+
let raw;
|
|
551
|
+
try {
|
|
552
|
+
raw = await fs.readFile(filePath, 'utf-8');
|
|
553
|
+
} catch (error) {
|
|
554
|
+
if (error.code === 'ENOENT') throw error;
|
|
555
|
+
throw createKanbanError(
|
|
556
|
+
'PARSE_ERROR',
|
|
557
|
+
`Epic file ${path.basename(filePath)} could not be read`,
|
|
558
|
+
'Fix permissions or restore the file from version control',
|
|
559
|
+
{ file: filePath, reason: error.message },
|
|
560
|
+
false,
|
|
561
|
+
500
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
let data;
|
|
565
|
+
try {
|
|
566
|
+
data = JSON.parse(raw);
|
|
567
|
+
} catch (error) {
|
|
568
|
+
throw createKanbanError(
|
|
569
|
+
'PARSE_ERROR',
|
|
570
|
+
`Epic file ${path.basename(filePath)} could not be parsed`,
|
|
571
|
+
'Fix the JSON syntax or restore the file from version control',
|
|
572
|
+
{ file: filePath, reason: error.message },
|
|
573
|
+
false,
|
|
574
|
+
500
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
return normalizeEpic({
|
|
578
|
+
...data,
|
|
579
|
+
id: data.id || path.basename(filePath, '.json')
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function listEpicEntities() {
|
|
584
|
+
await ensureBacklogDir();
|
|
585
|
+
const epics = [];
|
|
586
|
+
try {
|
|
587
|
+
const files = (await fs.readdir(EPICS_DIR))
|
|
588
|
+
.filter((file) => file.endsWith('.json'))
|
|
589
|
+
.sort((left, right) => left.localeCompare(right));
|
|
590
|
+
for (const file of files) {
|
|
591
|
+
try {
|
|
592
|
+
epics.push(await parseJsonEpic(path.join(EPICS_DIR, file)));
|
|
593
|
+
} catch (error) {
|
|
594
|
+
console.error(` parse error ${file}: ${error.message}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
} catch (error) {
|
|
598
|
+
if (error.code !== 'ENOENT') throw error;
|
|
599
|
+
}
|
|
600
|
+
return epics;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function writeEpic(epic) {
|
|
604
|
+
const normalized = normalizeEpic(epic);
|
|
605
|
+
if (!normalizeEpicId(normalized.id)) {
|
|
606
|
+
throw createKanbanError(
|
|
607
|
+
'VALIDATION_ERROR',
|
|
608
|
+
'epic id must look like E001',
|
|
609
|
+
'Use an epic id such as E001',
|
|
610
|
+
{ epic_id: normalized.id },
|
|
611
|
+
false,
|
|
612
|
+
400
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
await ensureBacklogDir();
|
|
616
|
+
const filePath = epicFilePath(normalized.id);
|
|
617
|
+
await fs.writeFile(filePath, JSON.stringify(serializeEpic(normalized), null, 2) + '\n', 'utf-8');
|
|
618
|
+
return parseJsonEpic(filePath);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function getEpicEntity(epicId) {
|
|
622
|
+
const id = normalizeEpicId(epicId) || normalizeString(epicId);
|
|
623
|
+
if (!id) {
|
|
624
|
+
throw createKanbanError(
|
|
625
|
+
'EPIC_NOT_FOUND',
|
|
626
|
+
`Epic ${epicId} was not found`,
|
|
627
|
+
'Call kanban_read with operation=list_epics to discover valid epic ids',
|
|
628
|
+
{ epic_id: epicId },
|
|
629
|
+
false,
|
|
630
|
+
404
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
const filePath = epicFilePath(normalizeEpicId(id) || id);
|
|
634
|
+
try {
|
|
635
|
+
return await parseJsonEpic(filePath);
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (error.code === 'ENOENT' || error.code === 'PARSE_ERROR') {
|
|
638
|
+
if (error.code === 'PARSE_ERROR') throw error;
|
|
639
|
+
throw createKanbanError(
|
|
640
|
+
'EPIC_NOT_FOUND',
|
|
641
|
+
`Epic ${epicId} was not found`,
|
|
642
|
+
'Call kanban_read with operation=list_epics to discover valid epic ids',
|
|
643
|
+
{ epic_id: epicId },
|
|
644
|
+
false,
|
|
645
|
+
404
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function findEpicsByTitle(title) {
|
|
653
|
+
const needle = normalizeString(title).toLowerCase();
|
|
654
|
+
if (!needle) return [];
|
|
655
|
+
const epics = await listEpicEntities();
|
|
656
|
+
return epics.filter((epic) => epic.title.toLowerCase() === needle);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function resolveEpicRef(ref, options = {}) {
|
|
660
|
+
if (isBlankEpicRef(ref)) {
|
|
661
|
+
return { epic_id: null, epic_group: '—' };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const asId = normalizeEpicId(ref);
|
|
665
|
+
if (asId) {
|
|
666
|
+
const epic = await getEpicEntity(asId);
|
|
667
|
+
return { epic_id: epic.id, epic_group: epic.title };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const matches = await findEpicsByTitle(ref);
|
|
671
|
+
if (matches.length === 1) {
|
|
672
|
+
return { epic_id: matches[0].id, epic_group: matches[0].title };
|
|
673
|
+
}
|
|
674
|
+
if (matches.length > 1) {
|
|
675
|
+
throw createKanbanError(
|
|
676
|
+
'AMBIGUOUS_EPIC',
|
|
677
|
+
`Multiple epics titled "${normalizeString(ref)}"`,
|
|
678
|
+
'Use an epic id (E001) instead of the title',
|
|
679
|
+
{ title: normalizeString(ref), epic_ids: matches.map((epic) => epic.id) },
|
|
680
|
+
false,
|
|
681
|
+
400
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (options.createIfMissing) {
|
|
686
|
+
const created = await doCreateEpic(normalizeString(ref), {});
|
|
687
|
+
return { epic_id: created.id, epic_group: created.title };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
throw createKanbanError(
|
|
691
|
+
'EPIC_NOT_FOUND',
|
|
692
|
+
`Epic "${normalizeString(ref)}" was not found`,
|
|
693
|
+
'Create it with epic_create or pass an existing epic id/title',
|
|
694
|
+
{ epic: normalizeString(ref) },
|
|
695
|
+
false,
|
|
696
|
+
404
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function doCreateEpic(title, extra = {}) {
|
|
701
|
+
if (!normalizeString(title)) {
|
|
702
|
+
throw createKanbanError(
|
|
703
|
+
'MISSING_REQUIRED_FIELD',
|
|
704
|
+
'title is required',
|
|
705
|
+
'Provide a non-empty title when creating an epic',
|
|
706
|
+
{ field: 'title' },
|
|
707
|
+
false,
|
|
708
|
+
400
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const nextId = await nextEpicNumber();
|
|
713
|
+
const epic = normalizeEpic({
|
|
714
|
+
id: `E${String(nextId).padStart(3, '0')}`,
|
|
715
|
+
title,
|
|
716
|
+
created: todayIso(),
|
|
717
|
+
description: extra.description,
|
|
718
|
+
goals: extra.goals,
|
|
719
|
+
in_scope: extra.in_scope,
|
|
720
|
+
out_of_scope: extra.out_of_scope,
|
|
721
|
+
notes: extra.notes
|
|
722
|
+
});
|
|
723
|
+
return writeEpic(epic);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function updateEpicEntity(epicId, patch) {
|
|
727
|
+
validatePatch(patch);
|
|
728
|
+
const current = await getEpicEntity(epicId);
|
|
729
|
+
const next = { ...current };
|
|
730
|
+
|
|
731
|
+
if (patch.title !== undefined) {
|
|
732
|
+
const title = normalizeString(patch.title);
|
|
733
|
+
if (!title) {
|
|
734
|
+
throw createKanbanError(
|
|
735
|
+
'VALIDATION_ERROR',
|
|
736
|
+
'title must be a non-empty string',
|
|
737
|
+
'Send a non-empty title or omit the field',
|
|
738
|
+
{ field: 'title' },
|
|
739
|
+
false,
|
|
740
|
+
400
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
next.title = title;
|
|
744
|
+
}
|
|
745
|
+
if (patch.description !== undefined) next.description = normalizeString(patch.description);
|
|
746
|
+
if (patch.goals !== undefined) next.goals = normalizeString(patch.goals);
|
|
747
|
+
if (patch.in_scope !== undefined) {
|
|
748
|
+
if (!Array.isArray(patch.in_scope)) {
|
|
749
|
+
throw createKanbanError(
|
|
750
|
+
'VALIDATION_ERROR',
|
|
751
|
+
'in_scope must be an array of strings',
|
|
752
|
+
'Send in_scope as an array',
|
|
753
|
+
{ field: 'in_scope' },
|
|
754
|
+
false,
|
|
755
|
+
400
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
next.in_scope = normalizeStringArray(patch.in_scope);
|
|
759
|
+
}
|
|
760
|
+
if (patch.out_of_scope !== undefined) {
|
|
761
|
+
if (!Array.isArray(patch.out_of_scope)) {
|
|
762
|
+
throw createKanbanError(
|
|
763
|
+
'VALIDATION_ERROR',
|
|
764
|
+
'out_of_scope must be an array of strings',
|
|
765
|
+
'Send out_of_scope as an array',
|
|
766
|
+
{ field: 'out_of_scope' },
|
|
767
|
+
false,
|
|
768
|
+
400
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
next.out_of_scope = normalizeStringArray(patch.out_of_scope);
|
|
772
|
+
}
|
|
773
|
+
if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
|
|
774
|
+
|
|
775
|
+
const saved = await writeEpic(next);
|
|
776
|
+
|
|
777
|
+
if (patch.title !== undefined && saved.title !== current.title) {
|
|
778
|
+
const tasks = await allTasks();
|
|
779
|
+
for (const task of tasks) {
|
|
780
|
+
if (task.epic_id === saved.id && task.epic_group !== saved.title) {
|
|
781
|
+
await updateTask(task.id, { epic_group: saved.title, _skipEpicResolve: true });
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return saved;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function migrateEpicGroups(options = {}) {
|
|
790
|
+
await ensureBacklogDir();
|
|
791
|
+
const tasks = await allTasks();
|
|
792
|
+
const existing = await listEpicEntities();
|
|
793
|
+
const byTitle = new Map(existing.map((epic) => [epic.title.toLowerCase(), epic]));
|
|
794
|
+
const created = [];
|
|
795
|
+
const linked = [];
|
|
796
|
+
const dryRun = Boolean(options.dryRun);
|
|
797
|
+
|
|
798
|
+
const labels = new Set();
|
|
799
|
+
for (const task of tasks) {
|
|
800
|
+
if (!task.epic_id && task.epic_group && task.epic_group !== '—') {
|
|
801
|
+
labels.add(task.epic_group);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
for (const label of labels) {
|
|
806
|
+
const key = label.toLowerCase();
|
|
807
|
+
let epic = byTitle.get(key);
|
|
808
|
+
if (!epic) {
|
|
809
|
+
if (dryRun) {
|
|
810
|
+
created.push({ title: label });
|
|
811
|
+
epic = { id: `(new)`, title: label };
|
|
812
|
+
} else {
|
|
813
|
+
epic = await doCreateEpic(label, {
|
|
814
|
+
description: `Migrated from epic_group label "${label}".`
|
|
815
|
+
});
|
|
816
|
+
created.push({ id: epic.id, title: epic.title });
|
|
817
|
+
}
|
|
818
|
+
byTitle.set(key, epic);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
for (const task of tasks) {
|
|
823
|
+
if (task.epic_id || !task.epic_group || task.epic_group === '—') continue;
|
|
824
|
+
const epic = byTitle.get(task.epic_group.toLowerCase());
|
|
825
|
+
if (!epic || !epic.id || epic.id === '(new)') {
|
|
826
|
+
linked.push({ task_id: task.id, epic_group: task.epic_group, dry_run: true });
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if (!dryRun) {
|
|
830
|
+
await updateTask(task.id, {
|
|
831
|
+
epic_id: epic.id,
|
|
832
|
+
epic_group: epic.title,
|
|
833
|
+
_skipEpicResolve: true
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
linked.push({ task_id: task.id, epic_id: epic.id, epic_group: epic.title });
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return { created, linked, dry_run: dryRun };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function taskMatchesEpicFilter(task, epicFilter) {
|
|
843
|
+
if (isBlankEpicRef(epicFilter)) return true;
|
|
844
|
+
const asId = normalizeEpicId(epicFilter);
|
|
845
|
+
if (asId) return task.epic_id === asId;
|
|
846
|
+
const needle = normalizeString(epicFilter).toLowerCase();
|
|
847
|
+
return task.epic_group.toLowerCase() === needle
|
|
848
|
+
|| (task.epic_id && task.epic_id.toLowerCase() === needle);
|
|
849
|
+
}
|
|
850
|
+
|
|
374
851
|
async function findFile(epicId) {
|
|
375
852
|
for (const col of COLS) {
|
|
376
853
|
const colDir = path.join(BACKLOG, col);
|
|
@@ -545,7 +1022,7 @@ function validatePatch(patch) {
|
|
|
545
1022
|
}
|
|
546
1023
|
}
|
|
547
1024
|
|
|
548
|
-
async function doCreate(title, column = 'planned',
|
|
1025
|
+
async function doCreate(title, column = 'planned', epicRef = '—', extra = {}) {
|
|
549
1026
|
if (!normalizeString(title)) {
|
|
550
1027
|
throw createKanbanError(
|
|
551
1028
|
'MISSING_REQUIRED_FIELD',
|
|
@@ -559,12 +1036,20 @@ async function doCreate(title, column = 'planned', epicGroup = '—', extra = {}
|
|
|
559
1036
|
|
|
560
1037
|
validateColumn(column, 'col');
|
|
561
1038
|
|
|
1039
|
+
let epicLink = { epic_id: null, epic_group: '—' };
|
|
1040
|
+
if (!isBlankEpicRef(epicRef)) {
|
|
1041
|
+
epicLink = await resolveEpicRef(epicRef, { createIfMissing: true });
|
|
1042
|
+
} else if (extra.epic_id) {
|
|
1043
|
+
epicLink = await resolveEpicRef(extra.epic_id, { createIfMissing: false });
|
|
1044
|
+
}
|
|
1045
|
+
|
|
562
1046
|
const nextId = await nextTaskNumber();
|
|
563
1047
|
const task = normalizeTask({
|
|
564
1048
|
id: String(nextId).padStart(3, '0'),
|
|
565
1049
|
title,
|
|
566
1050
|
column,
|
|
567
|
-
|
|
1051
|
+
epic_id: epicLink.epic_id,
|
|
1052
|
+
epic_group: epicLink.epic_group,
|
|
568
1053
|
created: todayIso(),
|
|
569
1054
|
description: extra.description,
|
|
570
1055
|
specs: extra.specs,
|
|
@@ -618,7 +1103,24 @@ async function updateTask(taskId, patch) {
|
|
|
618
1103
|
}
|
|
619
1104
|
next.title = title;
|
|
620
1105
|
}
|
|
621
|
-
if (patch.
|
|
1106
|
+
if (!patch._skipEpicResolve) {
|
|
1107
|
+
if (patch.epic_id !== undefined || patch.epic !== undefined || patch.epic_group !== undefined) {
|
|
1108
|
+
const ref = patch.epic_id !== undefined
|
|
1109
|
+
? patch.epic_id
|
|
1110
|
+
: (patch.epic !== undefined ? patch.epic : patch.epic_group);
|
|
1111
|
+
if (isBlankEpicRef(ref)) {
|
|
1112
|
+
next.epic_id = null;
|
|
1113
|
+
next.epic_group = '—';
|
|
1114
|
+
} else {
|
|
1115
|
+
const link = await resolveEpicRef(ref, { createIfMissing: Boolean(patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined) });
|
|
1116
|
+
next.epic_id = link.epic_id;
|
|
1117
|
+
next.epic_group = link.epic_group;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
} else {
|
|
1121
|
+
if (patch.epic_id !== undefined) next.epic_id = normalizeEpicId(patch.epic_id);
|
|
1122
|
+
if (patch.epic_group !== undefined) next.epic_group = normalizeString(patch.epic_group, '—') || '—';
|
|
1123
|
+
}
|
|
622
1124
|
if (patch.description !== undefined) next.description = normalizeString(patch.description);
|
|
623
1125
|
if (patch.specs !== undefined) next.specs = normalizeString(patch.specs);
|
|
624
1126
|
if (patch.in_scope !== undefined) {
|
|
@@ -764,22 +1266,38 @@ module.exports = {
|
|
|
764
1266
|
ensureBacklogDir,
|
|
765
1267
|
parseEpic,
|
|
766
1268
|
allEpics,
|
|
1269
|
+
allTasks,
|
|
767
1270
|
findFile,
|
|
768
1271
|
getTask,
|
|
769
1272
|
shapeTask,
|
|
1273
|
+
shapeEpic,
|
|
770
1274
|
updateTask,
|
|
771
1275
|
migrateAll,
|
|
1276
|
+
migrateEpicGroups,
|
|
772
1277
|
doMove,
|
|
773
1278
|
doToggle,
|
|
774
1279
|
doUpdate,
|
|
775
1280
|
doCreate,
|
|
1281
|
+
doCreateEpic,
|
|
1282
|
+
updateEpicEntity,
|
|
1283
|
+
getEpicEntity,
|
|
1284
|
+
listEpicEntities,
|
|
1285
|
+
resolveEpicRef,
|
|
1286
|
+
taskMatchesEpicFilter,
|
|
776
1287
|
createKanbanError,
|
|
777
1288
|
createFieldWarnings,
|
|
1289
|
+
createEpicFieldWarnings,
|
|
778
1290
|
missingRecommendedCreateFields,
|
|
1291
|
+
missingRecommendedEpicCreateFields,
|
|
779
1292
|
getProgress,
|
|
1293
|
+
getEpicProgress,
|
|
1294
|
+
deriveEpicStatus,
|
|
780
1295
|
resolveTaskId,
|
|
781
1296
|
COLS,
|
|
782
1297
|
STATUS_MAP,
|
|
783
1298
|
VIEW_FIELDS,
|
|
784
|
-
|
|
1299
|
+
EPIC_VIEW_FIELDS,
|
|
1300
|
+
RECOMMENDED_CREATE_FIELDS,
|
|
1301
|
+
RECOMMENDED_EPIC_CREATE_FIELDS,
|
|
1302
|
+
EPICS_DIR
|
|
785
1303
|
};
|