diff --git a/.project/kanban/doing/DEW-0005.md b/.project/kanban/done/DEW-0005.md similarity index 100% rename from .project/kanban/doing/DEW-0005.md rename to .project/kanban/done/DEW-0005.md diff --git a/.project/kanban/backlog/DEW-0006.md b/.project/kanban/done/DEW-0006.md similarity index 100% rename from .project/kanban/backlog/DEW-0006.md rename to .project/kanban/done/DEW-0006.md diff --git a/.project/kanban/backlog/DEW-0015.md b/.project/kanban/done/DEW-0015.md similarity index 100% rename from .project/kanban/backlog/DEW-0015.md rename to .project/kanban/done/DEW-0015.md diff --git a/packages/kanban/lib/src/commands/archive_command.dart b/packages/kanban/lib/src/commands/archive_command.dart new file mode 100644 index 0000000..0e1ee60 --- /dev/null +++ b/packages/kanban/lib/src/commands/archive_command.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:dew_core/dew_core.dart'; +import '../kanban_config.dart'; +import 'package:path/path.dart' as p; + +import '../ticket_store.dart'; + +class ArchiveCommand extends DewCommand with DewToolCommand { + ArchiveCommand() { + argParser.addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID to archive.'); + } + + @override + final String name = 'archive'; + + @override + final String description = 'Archive a ticket (moves it to the archive column).'; + + @override + final String toolName = 'kanban_archive_ticket'; + + @override + Future callAsTool(Map args) async { + final id = (args['id'] as String).toUpperCase(); + + final context = await ProjectContext.find(); + final config = context.config.kanban; + final kanbanDir = p.join(context.root, '.project', 'kanban'); + + final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix); + final ticket = await store.findById(id); + if (ticket == null) throw ArgumentError('Ticket $id not found.'); + if (ticket.column == 'archive') return '$id is already archived.'; + + final archiveDir = Directory(p.join(kanbanDir, 'archive')); + await archiveDir.create(recursive: true); + + // Move the file directly rather than through update() so we don't write + // the column back into the ticket frontmatter (it's derived from the dir). + final srcFile = File(p.join(kanbanDir, ticket.column, '$id.md')); + final dstFile = File(p.join(archiveDir.path, '$id.md')); + await srcFile.rename(dstFile.path); + + return 'Archived $id.'; + } +} diff --git a/packages/kanban/lib/src/commands/list_command.dart b/packages/kanban/lib/src/commands/list_command.dart index dd9d98a..9059779 100644 --- a/packages/kanban/lib/src/commands/list_command.dart +++ b/packages/kanban/lib/src/commands/list_command.dart @@ -11,7 +11,8 @@ class ListCommand extends DewCommand with DewToolCommand { ..addOption('column', abbr: 'c', help: 'Filter to tickets in this column.') ..addOption('type', abbr: 't', help: 'Filter to tickets of this type.') ..addOption('label', help: 'Filter to tickets with this label.') - ..addOption('milestone', help: 'Filter to tickets in this milestone.'); + ..addOption('milestone', help: 'Filter to tickets in this milestone.') + ..addFlag('include-archived', help: 'Include archived tickets.', negatable: false); } @override @@ -29,13 +30,14 @@ class ListCommand extends DewCommand with DewToolCommand { final typeFilter = args['type'] as String?; final labelFilter = args['label'] as String?; final milestoneFilter = args['milestone'] as String?; + final includeArchived = args['include-archived'] as bool? ?? false; final context = await ProjectContext.find(); final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: context.config.kanban.prefix, ); - var tickets = await store.list(); + var tickets = await store.list(includeArchived: includeArchived); if (columnFilter != null) { tickets = tickets.where((t) => t.column == columnFilter).toList(); diff --git a/packages/kanban/lib/src/commands/search_command.dart b/packages/kanban/lib/src/commands/search_command.dart index f8b478d..a9fe46b 100644 --- a/packages/kanban/lib/src/commands/search_command.dart +++ b/packages/kanban/lib/src/commands/search_command.dart @@ -16,7 +16,8 @@ class SearchCommand extends DewCommand with DewToolCommand { ..addOption('column', abbr: 'c', help: 'Restrict search to this column.') ..addOption('type', abbr: 't', help: 'Restrict search to this ticket type.') ..addOption('label', help: 'Restrict search to tickets with this label.') - ..addOption('milestone', help: 'Restrict search to tickets in this milestone.'); + ..addOption('milestone', help: 'Restrict search to tickets in this milestone.') + ..addFlag('include-archived', help: 'Include archived tickets.', negatable: false); } @override @@ -35,13 +36,14 @@ class SearchCommand extends DewCommand with DewToolCommand { final typeFilter = args['type'] as String?; final labelFilter = args['label'] as String?; final milestoneFilter = args['milestone'] as String?; + final includeArchived = args['include-archived'] as bool? ?? false; final context = await ProjectContext.find(); final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: context.config.kanban.prefix, ); - var tickets = await store.list(); + var tickets = await store.list(includeArchived: includeArchived); if (columnFilter != null) { tickets = tickets.where((t) => t.column == columnFilter).toList(); diff --git a/packages/kanban/lib/src/dew_kanban_base.dart b/packages/kanban/lib/src/dew_kanban_base.dart index 76ea027..bdc1067 100644 --- a/packages/kanban/lib/src/dew_kanban_base.dart +++ b/packages/kanban/lib/src/dew_kanban_base.dart @@ -1,6 +1,7 @@ import 'package:dew_core/dew_core.dart'; import 'commands/add_comment_command.dart'; +import 'commands/archive_command.dart'; import 'commands/board_command.dart'; import 'commands/create_command.dart'; import 'commands/delete_command.dart'; @@ -23,6 +24,7 @@ class KanbanCommand extends DewCommand { addSubcommand(GetCommand()); addSubcommand(UpdateCommand()); addSubcommand(DeleteCommand()); + addSubcommand(ArchiveCommand()); addSubcommand(MoveCommand()); addSubcommand(SearchCommand()); addSubcommand(AddCommentCommand()); diff --git a/packages/kanban/lib/src/ticket_store.dart b/packages/kanban/lib/src/ticket_store.dart index f87c30f..d081961 100644 --- a/packages/kanban/lib/src/ticket_store.dart +++ b/packages/kanban/lib/src/ticket_store.dart @@ -42,7 +42,7 @@ class TicketStore { return Ticket.fromFileContent(id, await found.file.readAsString(), found.column); } - Future> list() async { + Future> list({bool includeArchived = false}) async { final dir = Directory(kanbanDir); if (!await dir.exists()) return const []; final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-\d{4}\.md$'); @@ -50,7 +50,8 @@ class TicketStore { await for (final entity in dir.list()) { if (entity is! Directory) continue; final col = p.basename(entity.path); - if (col == 'archive' || col == 'attachments') continue; + if (col == 'attachments') continue; + if (col == 'archive' && !includeArchived) continue; await for (final file in entity.list()) { if (file is! File) continue; final name = p.basename(file.path); diff --git a/packages/kanban/test/dew_kanban_test.dart b/packages/kanban/test/dew_kanban_test.dart index a0f470f..71c7292 100644 --- a/packages/kanban/test/dew_kanban_test.dart +++ b/packages/kanban/test/dew_kanban_test.dart @@ -18,7 +18,7 @@ void main() { expect( cmd.subcommands.keys, containsAll([ - 'create', 'list', 'board', 'get', 'update', 'delete', + 'create', 'list', 'board', 'get', 'update', 'delete', 'archive', 'move', 'search', 'comment', 'config', 'stats', 'link', 'unlink', ]), ); @@ -36,7 +36,7 @@ void main() { final registry = CommandRegistry(); registerCommands(registry); final tools = registry.mcpTools; - expect(tools, hasLength(13)); + expect(tools, hasLength(14)); final names = tools.map((t) => t.name).toSet(); expect(names, { 'kanban_create_ticket', @@ -45,6 +45,7 @@ void main() { 'kanban_get_ticket', 'kanban_update_ticket', 'kanban_delete_ticket', + 'kanban_archive_ticket', 'kanban_move_ticket', 'kanban_search_tickets', 'kanban_add_comment', @@ -384,6 +385,22 @@ dew: expect(all.map((t) => t.id), ['TEST-0001', 'TEST-0002']); }); + test('list excludes archive by default, includes with flag', () async { + final store = makeStore(); + await store.create(title: 'Active', type: 'task', column: 'todo'); + // Manually move to archive dir to simulate archived state. + final kanbanDir = Directory(p.join(tempDir.path, 'kanban')); + final archiveDir = Directory(p.join(kanbanDir.path, 'archive')); + await archiveDir.create(recursive: true); + final src = File(p.join(kanbanDir.path, 'todo', 'TEST-0001.md')); + await src.rename(p.join(archiveDir.path, 'TEST-0001.md')); + + expect(await store.list(), isEmpty); + final withArchive = await store.list(includeArchived: true); + expect(withArchive, hasLength(1)); + expect(withArchive.first.column, 'archive'); + }); + test('update patches specified fields', () async { final store = makeStore(); await store.create(title: 'Old', type: 'task', column: 'todo');