@backlog-md/core 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -14
- package/dist/abstractions/index.d.ts +2 -2
- package/dist/abstractions/index.d.ts.map +1 -1
- package/dist/core/Core.d.ts +151 -1
- package/dist/core/Core.d.ts.map +1 -1
- package/dist/core/Core.js +672 -21
- package/dist/core/Core.js.map +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/index.d.ts +5 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/markdown/index.d.ts +56 -1
- package/dist/markdown/index.d.ts.map +1 -1
- package/dist/markdown/index.js +179 -5
- package/dist/markdown/index.js.map +1 -1
- package/dist/test-adapters/MockGitAdapter.d.ts +1 -1
- package/dist/test-adapters/MockGitAdapter.d.ts.map +1 -1
- package/dist/test-adapters/index.d.ts +5 -4
- package/dist/test-adapters/index.d.ts.map +1 -1
- package/dist/test-adapters/index.js +4 -6
- package/dist/test-adapters/index.js.map +1 -1
- package/dist/types/index.d.ts +52 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/index.d.ts +2 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/milestones.d.ts +65 -0
- package/dist/utils/milestones.d.ts.map +1 -0
- package/dist/utils/milestones.js +187 -0
- package/dist/utils/milestones.js.map +1 -0
- package/dist/utils/sorting.d.ts.map +1 -1
- package/dist/utils/sorting.js +2 -3
- package/dist/utils/sorting.js.map +1 -1
- package/package.json +10 -18
package/dist/core/Core.js
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Provides a runtime-agnostic API for managing Backlog.md projects
|
|
5
5
|
* by accepting adapter implementations for I/O operations.
|
|
6
6
|
*/
|
|
7
|
+
import { extractMilestoneIdFromFilename, extractTaskIndexFromPath, getMilestoneFilename, parseMilestoneMarkdown, parseTaskMarkdown, serializeMilestoneMarkdown, serializeTaskMarkdown, } from "../markdown";
|
|
8
|
+
import { groupTasksByMilestone, groupTasksByStatus, milestoneKey, sortTasks, sortTasksBy, } from "../utils";
|
|
7
9
|
import { parseBacklogConfig, serializeBacklogConfig } from "./config-parser";
|
|
8
|
-
import { parseTaskMarkdown, extractTaskIndexFromPath } from "../markdown";
|
|
9
|
-
import { sortTasks, sortTasksBy, groupTasksByStatus } from "../utils";
|
|
10
10
|
/**
|
|
11
11
|
* Core class for Backlog.md operations
|
|
12
12
|
*
|
|
@@ -243,10 +243,14 @@ export class Core {
|
|
|
243
243
|
entries = entries.sort((a, b) => {
|
|
244
244
|
const aNum = parseInt(a.id.replace(/\D/g, ""), 10) || 0;
|
|
245
245
|
const bNum = parseInt(b.id.replace(/\D/g, ""), 10) || 0;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
? bNum - aNum
|
|
249
|
-
|
|
246
|
+
if (source === "completed") {
|
|
247
|
+
// Completed: use completedSortByIdDesc option
|
|
248
|
+
return completedSortByIdDesc ? bNum - aNum : aNum - bNum;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
// Tasks: use tasksSortDirection option
|
|
252
|
+
return tasksSortDirection === "desc" ? bNum - aNum : aNum - bNum;
|
|
253
|
+
}
|
|
250
254
|
});
|
|
251
255
|
const total = entries.length;
|
|
252
256
|
const pageEntries = entries.slice(offset, offset + limit);
|
|
@@ -286,10 +290,14 @@ export class Core {
|
|
|
286
290
|
entries = entries.sort((a, b) => {
|
|
287
291
|
const aNum = parseInt(a.id.replace(/\D/g, ""), 10) || 0;
|
|
288
292
|
const bNum = parseInt(b.id.replace(/\D/g, ""), 10) || 0;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
? bNum - aNum
|
|
292
|
-
|
|
293
|
+
if (source === "completed") {
|
|
294
|
+
// Completed: use completedSortByIdDesc option
|
|
295
|
+
return completedSortByIdDesc ? bNum - aNum : aNum - bNum;
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
// Tasks: use sortDirection option
|
|
299
|
+
return sortDirection === "desc" ? bNum - aNum : aNum - bNum;
|
|
300
|
+
}
|
|
293
301
|
});
|
|
294
302
|
const total = entries.length;
|
|
295
303
|
const pageEntries = entries.slice(currentOffset, currentOffset + limit);
|
|
@@ -310,7 +318,7 @@ export class Core {
|
|
|
310
318
|
*/
|
|
311
319
|
getConfig() {
|
|
312
320
|
this.ensureInitialized();
|
|
313
|
-
return this.
|
|
321
|
+
return this.safeConfig;
|
|
314
322
|
}
|
|
315
323
|
/**
|
|
316
324
|
* List all tasks, optionally filtered
|
|
@@ -325,13 +333,18 @@ export class Core {
|
|
|
325
333
|
tasks = tasks.filter((t) => t.status === filter.status);
|
|
326
334
|
}
|
|
327
335
|
if (filter?.assignee) {
|
|
328
|
-
|
|
336
|
+
const assignee = filter.assignee;
|
|
337
|
+
tasks = tasks.filter((t) => t.assignee.includes(assignee));
|
|
329
338
|
}
|
|
330
339
|
if (filter?.priority) {
|
|
331
340
|
tasks = tasks.filter((t) => t.priority === filter.priority);
|
|
332
341
|
}
|
|
342
|
+
if (filter?.milestone) {
|
|
343
|
+
const filterKey = milestoneKey(filter.milestone);
|
|
344
|
+
tasks = tasks.filter((t) => milestoneKey(t.milestone) === filterKey);
|
|
345
|
+
}
|
|
333
346
|
if (filter?.labels && filter.labels.length > 0) {
|
|
334
|
-
tasks = tasks.filter((t) => filter.labels
|
|
347
|
+
tasks = tasks.filter((t) => filter.labels?.some((label) => t.labels.includes(label)));
|
|
335
348
|
}
|
|
336
349
|
if (filter?.parentTaskId) {
|
|
337
350
|
tasks = tasks.filter((t) => t.parentTaskId === filter.parentTaskId);
|
|
@@ -348,7 +361,232 @@ export class Core {
|
|
|
348
361
|
getTasksByStatus() {
|
|
349
362
|
this.ensureInitialized();
|
|
350
363
|
const tasks = Array.from(this.tasks.values());
|
|
351
|
-
return groupTasksByStatus(tasks, this.
|
|
364
|
+
return groupTasksByStatus(tasks, this.safeConfig.statuses);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Get tasks grouped by milestone
|
|
368
|
+
*
|
|
369
|
+
* Returns a MilestoneSummary with:
|
|
370
|
+
* - milestones: List of milestone IDs in display order
|
|
371
|
+
* - buckets: Array of MilestoneBucket with tasks, progress, status counts
|
|
372
|
+
*
|
|
373
|
+
* The first bucket is always "Tasks without milestone".
|
|
374
|
+
* Each bucket includes progress percentage based on done status.
|
|
375
|
+
*
|
|
376
|
+
* @example
|
|
377
|
+
* ```typescript
|
|
378
|
+
* const summary = core.getTasksByMilestone();
|
|
379
|
+
* for (const bucket of summary.buckets) {
|
|
380
|
+
* console.log(`${bucket.label}: ${bucket.progress}% complete`);
|
|
381
|
+
* console.log(` ${bucket.doneCount}/${bucket.total} tasks done`);
|
|
382
|
+
* }
|
|
383
|
+
* ```
|
|
384
|
+
*/
|
|
385
|
+
getTasksByMilestone() {
|
|
386
|
+
this.ensureInitialized();
|
|
387
|
+
const tasks = Array.from(this.tasks.values());
|
|
388
|
+
return groupTasksByMilestone(tasks, this.safeConfig.milestones, this.safeConfig.statuses);
|
|
389
|
+
}
|
|
390
|
+
// =========================================================================
|
|
391
|
+
// Milestone CRUD Operations
|
|
392
|
+
// =========================================================================
|
|
393
|
+
/**
|
|
394
|
+
* Get the milestones directory path
|
|
395
|
+
*/
|
|
396
|
+
getMilestonesDir() {
|
|
397
|
+
return this.fs.join(this.projectRoot, "backlog", "milestones");
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* List all milestones from the milestones directory
|
|
401
|
+
*
|
|
402
|
+
* @returns Array of Milestone objects sorted by ID
|
|
403
|
+
*/
|
|
404
|
+
async listMilestones() {
|
|
405
|
+
const milestonesDir = this.getMilestonesDir();
|
|
406
|
+
// Check if directory exists
|
|
407
|
+
if (!(await this.fs.exists(milestonesDir))) {
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
const entries = await this.fs.readDir(milestonesDir);
|
|
411
|
+
const milestones = [];
|
|
412
|
+
for (const entry of entries) {
|
|
413
|
+
// Skip non-milestone files
|
|
414
|
+
if (!entry.endsWith(".md"))
|
|
415
|
+
continue;
|
|
416
|
+
if (entry.toLowerCase() === "readme.md")
|
|
417
|
+
continue;
|
|
418
|
+
const milestoneId = extractMilestoneIdFromFilename(entry);
|
|
419
|
+
if (!milestoneId)
|
|
420
|
+
continue;
|
|
421
|
+
const filepath = this.fs.join(milestonesDir, entry);
|
|
422
|
+
// Skip directories
|
|
423
|
+
if (await this.fs.isDirectory(filepath))
|
|
424
|
+
continue;
|
|
425
|
+
try {
|
|
426
|
+
const content = await this.fs.readFile(filepath);
|
|
427
|
+
milestones.push(parseMilestoneMarkdown(content));
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
console.warn(`Failed to parse milestone file ${filepath}:`, error);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// Sort by ID for consistent ordering
|
|
434
|
+
return milestones.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Load a single milestone by ID
|
|
438
|
+
*
|
|
439
|
+
* @param id - Milestone ID (e.g., "m-0")
|
|
440
|
+
* @returns Milestone or null if not found
|
|
441
|
+
*/
|
|
442
|
+
async loadMilestone(id) {
|
|
443
|
+
const milestonesDir = this.getMilestonesDir();
|
|
444
|
+
if (!(await this.fs.exists(milestonesDir))) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
const entries = await this.fs.readDir(milestonesDir);
|
|
448
|
+
// Find file matching the ID
|
|
449
|
+
const milestoneFile = entries.find((entry) => {
|
|
450
|
+
const fileId = extractMilestoneIdFromFilename(entry);
|
|
451
|
+
return fileId === id;
|
|
452
|
+
});
|
|
453
|
+
if (!milestoneFile) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
const filepath = this.fs.join(milestonesDir, milestoneFile);
|
|
457
|
+
try {
|
|
458
|
+
const content = await this.fs.readFile(filepath);
|
|
459
|
+
return parseMilestoneMarkdown(content);
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Create a new milestone
|
|
467
|
+
*
|
|
468
|
+
* @param input - Milestone creation input
|
|
469
|
+
* @returns Created milestone
|
|
470
|
+
*/
|
|
471
|
+
async createMilestone(input) {
|
|
472
|
+
const milestonesDir = this.getMilestonesDir();
|
|
473
|
+
// Ensure milestones directory exists
|
|
474
|
+
await this.fs.createDir(milestonesDir, { recursive: true });
|
|
475
|
+
// Find next available milestone ID
|
|
476
|
+
const entries = await this.fs.readDir(milestonesDir).catch(() => []);
|
|
477
|
+
const existingIds = entries
|
|
478
|
+
.map((f) => {
|
|
479
|
+
const match = f.match(/^m-(\d+)/);
|
|
480
|
+
return match?.[1] ? parseInt(match[1], 10) : -1;
|
|
481
|
+
})
|
|
482
|
+
.filter((id) => id >= 0);
|
|
483
|
+
const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 0;
|
|
484
|
+
const id = `m-${nextId}`;
|
|
485
|
+
const description = input.description || `Milestone: ${input.title}`;
|
|
486
|
+
// Create a temporary milestone to generate content
|
|
487
|
+
const tempMilestone = {
|
|
488
|
+
id,
|
|
489
|
+
title: input.title,
|
|
490
|
+
description,
|
|
491
|
+
rawContent: "",
|
|
492
|
+
tasks: [],
|
|
493
|
+
};
|
|
494
|
+
// Generate content
|
|
495
|
+
const content = serializeMilestoneMarkdown(tempMilestone);
|
|
496
|
+
// Create the final milestone with correct rawContent
|
|
497
|
+
const milestone = {
|
|
498
|
+
id,
|
|
499
|
+
title: input.title,
|
|
500
|
+
description,
|
|
501
|
+
rawContent: content,
|
|
502
|
+
tasks: [],
|
|
503
|
+
};
|
|
504
|
+
// Write file
|
|
505
|
+
const filename = getMilestoneFilename(id, input.title);
|
|
506
|
+
const filepath = this.fs.join(milestonesDir, filename);
|
|
507
|
+
await this.fs.writeFile(filepath, content);
|
|
508
|
+
return milestone;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Update an existing milestone
|
|
512
|
+
*
|
|
513
|
+
* @param id - Milestone ID to update
|
|
514
|
+
* @param input - Fields to update
|
|
515
|
+
* @returns Updated milestone or null if not found
|
|
516
|
+
*/
|
|
517
|
+
async updateMilestone(id, input) {
|
|
518
|
+
const existing = await this.loadMilestone(id);
|
|
519
|
+
if (!existing) {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
const milestonesDir = this.getMilestonesDir();
|
|
523
|
+
const entries = await this.fs.readDir(milestonesDir);
|
|
524
|
+
// Find the current file
|
|
525
|
+
const currentFile = entries.find((entry) => {
|
|
526
|
+
const fileId = extractMilestoneIdFromFilename(entry);
|
|
527
|
+
return fileId === id;
|
|
528
|
+
});
|
|
529
|
+
if (!currentFile) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
// Build updated values
|
|
533
|
+
const newTitle = input.title ?? existing.title;
|
|
534
|
+
const newDescription = input.description ?? existing.description;
|
|
535
|
+
// Create a temporary milestone to generate content
|
|
536
|
+
const tempMilestone = {
|
|
537
|
+
id: existing.id,
|
|
538
|
+
title: newTitle,
|
|
539
|
+
description: newDescription,
|
|
540
|
+
rawContent: "",
|
|
541
|
+
tasks: existing.tasks,
|
|
542
|
+
};
|
|
543
|
+
// Generate new content
|
|
544
|
+
const content = serializeMilestoneMarkdown(tempMilestone);
|
|
545
|
+
// Create the final updated milestone
|
|
546
|
+
const updated = {
|
|
547
|
+
id: existing.id,
|
|
548
|
+
title: newTitle,
|
|
549
|
+
description: newDescription,
|
|
550
|
+
rawContent: content,
|
|
551
|
+
tasks: existing.tasks,
|
|
552
|
+
};
|
|
553
|
+
// Delete old file
|
|
554
|
+
const oldPath = this.fs.join(milestonesDir, currentFile);
|
|
555
|
+
await this.fs.deleteFile(oldPath);
|
|
556
|
+
// Write new file (with potentially new filename if title changed)
|
|
557
|
+
const newFilename = getMilestoneFilename(id, updated.title);
|
|
558
|
+
const newPath = this.fs.join(milestonesDir, newFilename);
|
|
559
|
+
await this.fs.writeFile(newPath, content);
|
|
560
|
+
return updated;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Delete a milestone
|
|
564
|
+
*
|
|
565
|
+
* @param id - Milestone ID to delete
|
|
566
|
+
* @returns true if deleted, false if not found
|
|
567
|
+
*/
|
|
568
|
+
async deleteMilestone(id) {
|
|
569
|
+
const milestonesDir = this.getMilestonesDir();
|
|
570
|
+
if (!(await this.fs.exists(milestonesDir))) {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
const entries = await this.fs.readDir(milestonesDir);
|
|
574
|
+
// Find file matching the ID
|
|
575
|
+
const milestoneFile = entries.find((entry) => {
|
|
576
|
+
const fileId = extractMilestoneIdFromFilename(entry);
|
|
577
|
+
return fileId === id;
|
|
578
|
+
});
|
|
579
|
+
if (!milestoneFile) {
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
const filepath = this.fs.join(milestonesDir, milestoneFile);
|
|
583
|
+
try {
|
|
584
|
+
await this.fs.deleteFile(filepath);
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
352
590
|
}
|
|
353
591
|
/**
|
|
354
592
|
* Get a single task by ID
|
|
@@ -403,7 +641,7 @@ export class Core {
|
|
|
403
641
|
const byStatus = new Map();
|
|
404
642
|
// Group all tasks by status first (without sorting)
|
|
405
643
|
const allGrouped = new Map();
|
|
406
|
-
for (const status of this.
|
|
644
|
+
for (const status of this.safeConfig.statuses) {
|
|
407
645
|
allGrouped.set(status, []);
|
|
408
646
|
}
|
|
409
647
|
for (const task of this.tasks.values()) {
|
|
@@ -416,7 +654,7 @@ export class Core {
|
|
|
416
654
|
}
|
|
417
655
|
}
|
|
418
656
|
// Paginate each status column
|
|
419
|
-
for (const status of this.
|
|
657
|
+
for (const status of this.safeConfig.statuses) {
|
|
420
658
|
let tasks = allGrouped.get(status) ?? [];
|
|
421
659
|
tasks = sortTasksBy(tasks, sortBy, sortDirection);
|
|
422
660
|
const total = tasks.length;
|
|
@@ -431,7 +669,7 @@ export class Core {
|
|
|
431
669
|
}
|
|
432
670
|
return {
|
|
433
671
|
byStatus,
|
|
434
|
-
statuses: this.
|
|
672
|
+
statuses: this.safeConfig.statuses,
|
|
435
673
|
};
|
|
436
674
|
}
|
|
437
675
|
/**
|
|
@@ -471,10 +709,16 @@ export class Core {
|
|
|
471
709
|
}
|
|
472
710
|
// --- Private methods ---
|
|
473
711
|
ensureInitialized() {
|
|
474
|
-
if (!this.initialized) {
|
|
475
|
-
throw new Error("Core not initialized. Call initialize() first.");
|
|
712
|
+
if ((!this.initialized && !this.lazyInitialized) || !this.config) {
|
|
713
|
+
throw new Error("Core not initialized. Call initialize() or initializeLazy() first.");
|
|
476
714
|
}
|
|
477
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* Get config with type safety (use after ensureInitialized)
|
|
718
|
+
*/
|
|
719
|
+
get safeConfig() {
|
|
720
|
+
return this.config;
|
|
721
|
+
}
|
|
478
722
|
applyFilters(tasks, filter) {
|
|
479
723
|
if (!filter)
|
|
480
724
|
return tasks;
|
|
@@ -483,13 +727,18 @@ export class Core {
|
|
|
483
727
|
result = result.filter((t) => t.status === filter.status);
|
|
484
728
|
}
|
|
485
729
|
if (filter.assignee) {
|
|
486
|
-
|
|
730
|
+
const assignee = filter.assignee;
|
|
731
|
+
result = result.filter((t) => t.assignee.includes(assignee));
|
|
487
732
|
}
|
|
488
733
|
if (filter.priority) {
|
|
489
734
|
result = result.filter((t) => t.priority === filter.priority);
|
|
490
735
|
}
|
|
736
|
+
if (filter.milestone) {
|
|
737
|
+
const filterKey = milestoneKey(filter.milestone);
|
|
738
|
+
result = result.filter((t) => milestoneKey(t.milestone) === filterKey);
|
|
739
|
+
}
|
|
491
740
|
if (filter.labels && filter.labels.length > 0) {
|
|
492
|
-
result = result.filter((t) => filter.labels
|
|
741
|
+
result = result.filter((t) => filter.labels?.some((label) => t.labels.includes(label)));
|
|
493
742
|
}
|
|
494
743
|
if (filter.parentTaskId) {
|
|
495
744
|
result = result.filter((t) => t.parentTaskId === filter.parentTaskId);
|
|
@@ -521,5 +770,407 @@ export class Core {
|
|
|
521
770
|
}
|
|
522
771
|
}
|
|
523
772
|
}
|
|
773
|
+
// =========================================================================
|
|
774
|
+
// Milestone-Task Sync Helpers
|
|
775
|
+
// =========================================================================
|
|
776
|
+
/**
|
|
777
|
+
* Add a task ID to a milestone's tasks array
|
|
778
|
+
*
|
|
779
|
+
* @param taskId - Task ID to add
|
|
780
|
+
* @param milestoneId - Milestone ID to update
|
|
781
|
+
*/
|
|
782
|
+
async addTaskToMilestone(taskId, milestoneId) {
|
|
783
|
+
const milestone = await this.loadMilestone(milestoneId);
|
|
784
|
+
if (!milestone) {
|
|
785
|
+
console.warn(`Milestone ${milestoneId} not found when adding task ${taskId}`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
// Check if task already in milestone
|
|
789
|
+
if (milestone.tasks.includes(taskId)) {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
// Update milestone with new task
|
|
793
|
+
const updatedMilestone = {
|
|
794
|
+
...milestone,
|
|
795
|
+
tasks: [...milestone.tasks, taskId],
|
|
796
|
+
};
|
|
797
|
+
await this.writeMilestoneFile(updatedMilestone);
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Remove a task ID from a milestone's tasks array
|
|
801
|
+
*
|
|
802
|
+
* @param taskId - Task ID to remove
|
|
803
|
+
* @param milestoneId - Milestone ID to update
|
|
804
|
+
*/
|
|
805
|
+
async removeTaskFromMilestone(taskId, milestoneId) {
|
|
806
|
+
const milestone = await this.loadMilestone(milestoneId);
|
|
807
|
+
if (!milestone) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
// Check if task is in milestone
|
|
811
|
+
if (!milestone.tasks.includes(taskId)) {
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
// Update milestone without the task
|
|
815
|
+
const updatedMilestone = {
|
|
816
|
+
...milestone,
|
|
817
|
+
tasks: milestone.tasks.filter((id) => id !== taskId),
|
|
818
|
+
};
|
|
819
|
+
await this.writeMilestoneFile(updatedMilestone);
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Write a milestone to disk
|
|
823
|
+
*/
|
|
824
|
+
async writeMilestoneFile(milestone) {
|
|
825
|
+
const milestonesDir = this.getMilestonesDir();
|
|
826
|
+
const entries = await this.fs.readDir(milestonesDir).catch(() => []);
|
|
827
|
+
// Find and delete the current file
|
|
828
|
+
const currentFile = entries.find((entry) => {
|
|
829
|
+
const fileId = extractMilestoneIdFromFilename(entry);
|
|
830
|
+
return fileId === milestone.id;
|
|
831
|
+
});
|
|
832
|
+
if (currentFile) {
|
|
833
|
+
const oldPath = this.fs.join(milestonesDir, currentFile);
|
|
834
|
+
await this.fs.deleteFile(oldPath).catch(() => { });
|
|
835
|
+
}
|
|
836
|
+
// Write new file
|
|
837
|
+
const content = serializeMilestoneMarkdown(milestone);
|
|
838
|
+
const filename = getMilestoneFilename(milestone.id, milestone.title);
|
|
839
|
+
const filepath = this.fs.join(milestonesDir, filename);
|
|
840
|
+
await this.fs.writeFile(filepath, content);
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Get the tasks directory path
|
|
844
|
+
*/
|
|
845
|
+
getTasksDir() {
|
|
846
|
+
return this.fs.join(this.projectRoot, "backlog", "tasks");
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Get the completed directory path
|
|
850
|
+
*/
|
|
851
|
+
getCompletedDir() {
|
|
852
|
+
return this.fs.join(this.projectRoot, "backlog", "completed");
|
|
853
|
+
}
|
|
854
|
+
// =========================================================================
|
|
855
|
+
// Task CRUD Operations
|
|
856
|
+
// =========================================================================
|
|
857
|
+
/**
|
|
858
|
+
* Create a new task
|
|
859
|
+
*
|
|
860
|
+
* @param input - Task creation input
|
|
861
|
+
* @returns Created task
|
|
862
|
+
*/
|
|
863
|
+
async createTask(input) {
|
|
864
|
+
this.ensureInitialized();
|
|
865
|
+
const tasksDir = this.getTasksDir();
|
|
866
|
+
// Ensure tasks directory exists
|
|
867
|
+
await this.fs.createDir(tasksDir, { recursive: true });
|
|
868
|
+
// Generate next task ID
|
|
869
|
+
const existingIds = Array.from(this.tasks.keys())
|
|
870
|
+
.map((id) => parseInt(id.replace(/\D/g, ""), 10))
|
|
871
|
+
.filter((n) => !Number.isNaN(n));
|
|
872
|
+
const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1;
|
|
873
|
+
const taskId = String(nextId);
|
|
874
|
+
// Build task object
|
|
875
|
+
const now = new Date().toISOString().split("T")[0];
|
|
876
|
+
const task = {
|
|
877
|
+
id: taskId,
|
|
878
|
+
title: input.title,
|
|
879
|
+
status: input.status || this.config?.defaultStatus || "To Do",
|
|
880
|
+
priority: input.priority,
|
|
881
|
+
assignee: input.assignee || [],
|
|
882
|
+
createdDate: now,
|
|
883
|
+
labels: input.labels || [],
|
|
884
|
+
milestone: input.milestone,
|
|
885
|
+
dependencies: input.dependencies || [],
|
|
886
|
+
parentTaskId: input.parentTaskId,
|
|
887
|
+
description: input.description,
|
|
888
|
+
implementationPlan: input.implementationPlan,
|
|
889
|
+
implementationNotes: input.implementationNotes,
|
|
890
|
+
acceptanceCriteriaItems: input.acceptanceCriteria?.map((ac, i) => ({
|
|
891
|
+
index: i + 1,
|
|
892
|
+
text: ac.text,
|
|
893
|
+
checked: ac.checked || false,
|
|
894
|
+
})),
|
|
895
|
+
rawContent: input.rawContent,
|
|
896
|
+
source: "local",
|
|
897
|
+
};
|
|
898
|
+
// Serialize and write file
|
|
899
|
+
const content = serializeTaskMarkdown(task);
|
|
900
|
+
const safeTitle = input.title
|
|
901
|
+
.replace(/[<>:"/\\|?*]/g, "")
|
|
902
|
+
.replace(/\s+/g, " ")
|
|
903
|
+
.slice(0, 50);
|
|
904
|
+
const filename = `${taskId} - ${safeTitle}.md`;
|
|
905
|
+
const filepath = this.fs.join(tasksDir, filename);
|
|
906
|
+
await this.fs.writeFile(filepath, content);
|
|
907
|
+
// Update in-memory cache
|
|
908
|
+
task.filePath = filepath;
|
|
909
|
+
this.tasks.set(taskId, task);
|
|
910
|
+
// Sync milestone if specified
|
|
911
|
+
if (input.milestone) {
|
|
912
|
+
await this.addTaskToMilestone(taskId, input.milestone);
|
|
913
|
+
}
|
|
914
|
+
return task;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Update an existing task
|
|
918
|
+
*
|
|
919
|
+
* @param id - Task ID to update
|
|
920
|
+
* @param input - Fields to update
|
|
921
|
+
* @returns Updated task or null if not found
|
|
922
|
+
*/
|
|
923
|
+
async updateTask(id, input) {
|
|
924
|
+
this.ensureInitialized();
|
|
925
|
+
const existing = this.tasks.get(id);
|
|
926
|
+
if (!existing) {
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
const oldMilestone = existing.milestone;
|
|
930
|
+
const newMilestone = input.milestone === null
|
|
931
|
+
? undefined
|
|
932
|
+
: input.milestone !== undefined
|
|
933
|
+
? input.milestone
|
|
934
|
+
: oldMilestone;
|
|
935
|
+
// Build updated task
|
|
936
|
+
const now = new Date().toISOString().split("T")[0];
|
|
937
|
+
const updated = {
|
|
938
|
+
...existing,
|
|
939
|
+
title: input.title ?? existing.title,
|
|
940
|
+
status: input.status ?? existing.status,
|
|
941
|
+
priority: input.priority ?? existing.priority,
|
|
942
|
+
milestone: newMilestone,
|
|
943
|
+
updatedDate: now,
|
|
944
|
+
description: input.description ?? existing.description,
|
|
945
|
+
implementationPlan: input.clearImplementationPlan
|
|
946
|
+
? undefined
|
|
947
|
+
: (input.implementationPlan ?? existing.implementationPlan),
|
|
948
|
+
implementationNotes: input.clearImplementationNotes
|
|
949
|
+
? undefined
|
|
950
|
+
: (input.implementationNotes ?? existing.implementationNotes),
|
|
951
|
+
ordinal: input.ordinal ?? existing.ordinal,
|
|
952
|
+
dependencies: input.dependencies ?? existing.dependencies,
|
|
953
|
+
};
|
|
954
|
+
// Handle label operations
|
|
955
|
+
if (input.labels) {
|
|
956
|
+
updated.labels = input.labels;
|
|
957
|
+
}
|
|
958
|
+
else {
|
|
959
|
+
if (input.addLabels) {
|
|
960
|
+
updated.labels = [...new Set([...updated.labels, ...input.addLabels])];
|
|
961
|
+
}
|
|
962
|
+
if (input.removeLabels) {
|
|
963
|
+
updated.labels = updated.labels.filter((l) => !input.removeLabels?.includes(l));
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
// Handle assignee
|
|
967
|
+
if (input.assignee) {
|
|
968
|
+
updated.assignee = input.assignee;
|
|
969
|
+
}
|
|
970
|
+
// Handle dependency operations
|
|
971
|
+
if (input.addDependencies) {
|
|
972
|
+
updated.dependencies = [...new Set([...updated.dependencies, ...input.addDependencies])];
|
|
973
|
+
}
|
|
974
|
+
if (input.removeDependencies) {
|
|
975
|
+
updated.dependencies = updated.dependencies.filter((d) => !input.removeDependencies?.includes(d));
|
|
976
|
+
}
|
|
977
|
+
// Handle acceptance criteria
|
|
978
|
+
if (input.acceptanceCriteria) {
|
|
979
|
+
updated.acceptanceCriteriaItems = input.acceptanceCriteria.map((ac, i) => ({
|
|
980
|
+
index: i + 1,
|
|
981
|
+
text: ac.text,
|
|
982
|
+
checked: ac.checked || false,
|
|
983
|
+
}));
|
|
984
|
+
}
|
|
985
|
+
// Serialize and write file
|
|
986
|
+
const content = serializeTaskMarkdown(updated);
|
|
987
|
+
// Delete old file if exists
|
|
988
|
+
if (existing.filePath) {
|
|
989
|
+
await this.fs.deleteFile(existing.filePath).catch(() => { });
|
|
990
|
+
}
|
|
991
|
+
// Write new file
|
|
992
|
+
const tasksDir = this.getTasksDir();
|
|
993
|
+
const safeTitle = updated.title
|
|
994
|
+
.replace(/[<>:"/\\|?*]/g, "")
|
|
995
|
+
.replace(/\s+/g, " ")
|
|
996
|
+
.slice(0, 50);
|
|
997
|
+
const filename = `${id} - ${safeTitle}.md`;
|
|
998
|
+
const filepath = this.fs.join(tasksDir, filename);
|
|
999
|
+
await this.fs.writeFile(filepath, content);
|
|
1000
|
+
// Update in-memory cache
|
|
1001
|
+
updated.filePath = filepath;
|
|
1002
|
+
this.tasks.set(id, updated);
|
|
1003
|
+
// Handle milestone sync
|
|
1004
|
+
const milestoneChanged = milestoneKey(oldMilestone) !== milestoneKey(newMilestone);
|
|
1005
|
+
if (milestoneChanged) {
|
|
1006
|
+
// Remove from old milestone
|
|
1007
|
+
if (oldMilestone) {
|
|
1008
|
+
await this.removeTaskFromMilestone(id, oldMilestone);
|
|
1009
|
+
}
|
|
1010
|
+
// Add to new milestone
|
|
1011
|
+
if (newMilestone) {
|
|
1012
|
+
await this.addTaskToMilestone(id, newMilestone);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return updated;
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Delete a task
|
|
1019
|
+
*
|
|
1020
|
+
* @param id - Task ID to delete
|
|
1021
|
+
* @returns true if deleted, false if not found
|
|
1022
|
+
*/
|
|
1023
|
+
async deleteTask(id) {
|
|
1024
|
+
this.ensureInitialized();
|
|
1025
|
+
const task = this.tasks.get(id);
|
|
1026
|
+
if (!task) {
|
|
1027
|
+
return false;
|
|
1028
|
+
}
|
|
1029
|
+
// Remove from milestone if assigned
|
|
1030
|
+
if (task.milestone) {
|
|
1031
|
+
await this.removeTaskFromMilestone(id, task.milestone);
|
|
1032
|
+
}
|
|
1033
|
+
// Delete file
|
|
1034
|
+
if (task.filePath) {
|
|
1035
|
+
try {
|
|
1036
|
+
await this.fs.deleteFile(task.filePath);
|
|
1037
|
+
}
|
|
1038
|
+
catch {
|
|
1039
|
+
// File may already be deleted
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
// Remove from in-memory cache
|
|
1043
|
+
this.tasks.delete(id);
|
|
1044
|
+
return true;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Archive a task (move from tasks/ to completed/)
|
|
1048
|
+
*
|
|
1049
|
+
* @param id - Task ID to archive
|
|
1050
|
+
* @returns Archived task or null if not found or already archived
|
|
1051
|
+
*/
|
|
1052
|
+
async archiveTask(id) {
|
|
1053
|
+
this.ensureInitialized();
|
|
1054
|
+
const task = this.tasks.get(id);
|
|
1055
|
+
if (!task) {
|
|
1056
|
+
return null;
|
|
1057
|
+
}
|
|
1058
|
+
// Check if already in completed
|
|
1059
|
+
if (task.source === "completed") {
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
const completedDir = this.getCompletedDir();
|
|
1063
|
+
// Ensure completed directory exists
|
|
1064
|
+
await this.fs.createDir(completedDir, { recursive: true });
|
|
1065
|
+
// Build new filepath in completed/
|
|
1066
|
+
const safeTitle = task.title
|
|
1067
|
+
.replace(/[<>:"/\\|?*]/g, "")
|
|
1068
|
+
.replace(/\s+/g, " ")
|
|
1069
|
+
.slice(0, 50);
|
|
1070
|
+
const filename = `${id} - ${safeTitle}.md`;
|
|
1071
|
+
const newFilepath = this.fs.join(completedDir, filename);
|
|
1072
|
+
// Delete old file
|
|
1073
|
+
if (task.filePath) {
|
|
1074
|
+
try {
|
|
1075
|
+
await this.fs.deleteFile(task.filePath);
|
|
1076
|
+
}
|
|
1077
|
+
catch {
|
|
1078
|
+
// File may not exist
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
// Update task
|
|
1082
|
+
const archived = {
|
|
1083
|
+
...task,
|
|
1084
|
+
source: "completed",
|
|
1085
|
+
filePath: newFilepath,
|
|
1086
|
+
};
|
|
1087
|
+
// Write to new location
|
|
1088
|
+
const content = serializeTaskMarkdown(archived);
|
|
1089
|
+
await this.fs.writeFile(newFilepath, content);
|
|
1090
|
+
// Update in-memory cache
|
|
1091
|
+
this.tasks.set(id, archived);
|
|
1092
|
+
// Update task index if lazy initialized
|
|
1093
|
+
if (this.lazyInitialized) {
|
|
1094
|
+
const entry = this.taskIndex.get(id);
|
|
1095
|
+
if (entry) {
|
|
1096
|
+
entry.source = "completed";
|
|
1097
|
+
entry.filePath = newFilepath;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return archived;
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Restore a task (move from completed/ to tasks/)
|
|
1104
|
+
*
|
|
1105
|
+
* @param id - Task ID to restore
|
|
1106
|
+
* @returns Restored task or null if not found or not archived
|
|
1107
|
+
*/
|
|
1108
|
+
async restoreTask(id) {
|
|
1109
|
+
this.ensureInitialized();
|
|
1110
|
+
const task = this.tasks.get(id);
|
|
1111
|
+
if (!task) {
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
// Check if in completed
|
|
1115
|
+
if (task.source !== "completed") {
|
|
1116
|
+
return null;
|
|
1117
|
+
}
|
|
1118
|
+
const tasksDir = this.getTasksDir();
|
|
1119
|
+
// Ensure tasks directory exists
|
|
1120
|
+
await this.fs.createDir(tasksDir, { recursive: true });
|
|
1121
|
+
// Build new filepath in tasks/
|
|
1122
|
+
const safeTitle = task.title
|
|
1123
|
+
.replace(/[<>:"/\\|?*]/g, "")
|
|
1124
|
+
.replace(/\s+/g, " ")
|
|
1125
|
+
.slice(0, 50);
|
|
1126
|
+
const filename = `${id} - ${safeTitle}.md`;
|
|
1127
|
+
const newFilepath = this.fs.join(tasksDir, filename);
|
|
1128
|
+
// Delete old file
|
|
1129
|
+
if (task.filePath) {
|
|
1130
|
+
try {
|
|
1131
|
+
await this.fs.deleteFile(task.filePath);
|
|
1132
|
+
}
|
|
1133
|
+
catch {
|
|
1134
|
+
// File may not exist
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
// Update task
|
|
1138
|
+
const restored = {
|
|
1139
|
+
...task,
|
|
1140
|
+
source: "local",
|
|
1141
|
+
filePath: newFilepath,
|
|
1142
|
+
};
|
|
1143
|
+
// Write to new location
|
|
1144
|
+
const content = serializeTaskMarkdown(restored);
|
|
1145
|
+
await this.fs.writeFile(newFilepath, content);
|
|
1146
|
+
// Update in-memory cache
|
|
1147
|
+
this.tasks.set(id, restored);
|
|
1148
|
+
// Update task index if lazy initialized
|
|
1149
|
+
if (this.lazyInitialized) {
|
|
1150
|
+
const entry = this.taskIndex.get(id);
|
|
1151
|
+
if (entry) {
|
|
1152
|
+
entry.source = "tasks";
|
|
1153
|
+
entry.filePath = newFilepath;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
return restored;
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Load specific tasks by their IDs (for lazy loading milestone tasks)
|
|
1160
|
+
*
|
|
1161
|
+
* @param ids - Array of task IDs to load
|
|
1162
|
+
* @returns Array of loaded tasks (missing tasks excluded)
|
|
1163
|
+
*/
|
|
1164
|
+
async loadTasksByIds(ids) {
|
|
1165
|
+
// If fully initialized, return from cache
|
|
1166
|
+
if (this.initialized) {
|
|
1167
|
+
return ids.map((id) => this.tasks.get(id)).filter((t) => t !== undefined);
|
|
1168
|
+
}
|
|
1169
|
+
// If lazy initialized, load on demand
|
|
1170
|
+
if (this.lazyInitialized) {
|
|
1171
|
+
return this.loadTasks(ids);
|
|
1172
|
+
}
|
|
1173
|
+
throw new Error("Core not initialized. Call initialize() or initializeLazy() first.");
|
|
1174
|
+
}
|
|
524
1175
|
}
|
|
525
1176
|
//# sourceMappingURL=Core.js.map
|