diff --git a/.project/kanban/backlog/DEW-0004.md b/.project/kanban/done/DEW-0004.md similarity index 100% rename from .project/kanban/backlog/DEW-0004.md rename to .project/kanban/done/DEW-0004.md diff --git a/.project/kanban/backlog/DEW-0012.md b/.project/kanban/done/DEW-0012.md similarity index 100% rename from .project/kanban/backlog/DEW-0012.md rename to .project/kanban/done/DEW-0012.md diff --git a/packages/kanban/lib/src/commands/board_command.dart b/packages/kanban/lib/src/commands/board_command.dart new file mode 100644 index 0000000..3a5a2f9 --- /dev/null +++ b/packages/kanban/lib/src/commands/board_command.dart @@ -0,0 +1,92 @@ +import 'package:dew_core/dew_core.dart'; +import '../kanban_config.dart'; +import 'package:path/path.dart' as p; + +import '../ticket.dart'; +import '../ticket_store.dart'; + +class BoardCommand extends DewCommand with DewToolCommand { + BoardCommand() { + argParser + ..addOption('type', abbr: 't', help: 'Filter tickets to this type.') + ..addOption('label', help: 'Filter tickets to this label.') + ..addOption('milestone', help: 'Filter tickets to this milestone.'); + } + + @override + final String name = 'board'; + + @override + final String description = 'Show ticket counts grouped by column and type.'; + + @override + final String toolName = 'kanban_board'; + + @override + Future callAsTool(Map args) async { + final typeFilter = args['type'] as String?; + final labelFilter = args['label'] as String?; + final milestoneFilter = args['milestone'] as String?; + + final context = await ProjectContext.find(); + final config = context.config.kanban; + final store = TicketStore( + kanbanDir: p.join(context.root, '.project', 'kanban'), + prefix: config.prefix, + ); + + var tickets = await store.list(); + if (typeFilter != null) { + tickets = tickets.where((t) => t.type == typeFilter).toList(); + } + if (labelFilter != null) { + tickets = tickets.where((t) => t.labels.contains(labelFilter)).toList(); + } + if (milestoneFilter != null) { + tickets = tickets.where((t) => t.milestones.contains(milestoneFilter)).toList(); + } + + // Index by column, preserving config order. + final byColumn = >{}; + for (final col in config.columns) { + byColumn[col.id] = []; + } + for (final t in tickets) { + (byColumn[t.column] ??= []).add(t); + } + + if (byColumn.values.every((l) => l.isEmpty)) { + return 'No tickets found.'; + } + + // Each box is at least wide enough for " ". + final Map> lines = {}; + for (final entry in byColumn.entries) { + if (entry.value.isEmpty) { + lines[entry.key] = [' (empty)']; + } else { + lines[entry.key] = entry.value + .map((t) => ' [${t.id}] (${t.type}) ${t.title}') + .toList(); + } + } + + final buf = StringBuffer(); + for (final entry in byColumn.entries) { + final col = entry.key; + final header = ' $col (${entry.value.length}) '; + final colLines = lines[col]!; + final contentWidth = [header.length, ...colLines.map((l) => l.length + 2)] + .reduce((a, b) => a > b ? a : b); + final divider = '─' * contentWidth; + buf.writeln('┌$divider┐'); + buf.writeln('│${header.padRight(contentWidth)}│'); + buf.writeln('├$divider┤'); + for (final line in colLines) { + buf.writeln('│${line.padRight(contentWidth)}│'); + } + buf.writeln('└$divider┘'); + } + return buf.toString().trimRight(); + } +} diff --git a/packages/kanban/lib/src/commands/list_command.dart b/packages/kanban/lib/src/commands/list_command.dart index 5b44f0b..dd9d98a 100644 --- a/packages/kanban/lib/src/commands/list_command.dart +++ b/packages/kanban/lib/src/commands/list_command.dart @@ -2,6 +2,7 @@ import 'package:dew_core/dew_core.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; +import '../ticket.dart'; import '../ticket_store.dart'; class ListCommand extends DewCommand with DewToolCommand { @@ -50,8 +51,28 @@ class ListCommand extends DewCommand with DewToolCommand { } if (tickets.isEmpty) return 'No tickets found.'; - return tickets - .map((t) => '[${t.id}] (${t.type}) [${t.column}] ${t.title}') - .join('\n'); + + // If a column filter is applied, skip the grouping header. + if (columnFilter != null) { + return tickets.map((t) => '[${t.id}] (${t.type}) ${t.title}').join('\n'); + } + + // Group by column and emit with headers. + final byColumn = >{}; + for (final t in tickets) { + (byColumn[t.column] ??= []).add(t); + } + final buf = StringBuffer(); + var first = true; + for (final column in byColumn.keys) { + if (!first) buf.writeln(); + first = false; + final count = byColumn[column]!.length; + buf.writeln('$column ($count)'); + for (final t in byColumn[column]!) { + buf.writeln(' [${t.id}] (${t.type}) ${t.title}'); + } + } + return buf.toString().trimRight(); } } diff --git a/packages/kanban/lib/src/dew_kanban_base.dart b/packages/kanban/lib/src/dew_kanban_base.dart index a67b2b2..76ea027 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/board_command.dart'; import 'commands/create_command.dart'; import 'commands/delete_command.dart'; import 'commands/get_command.dart'; @@ -18,6 +19,7 @@ class KanbanCommand extends DewCommand { KanbanCommand() { addSubcommand(CreateCommand()); addSubcommand(ListCommand()); + addSubcommand(BoardCommand()); addSubcommand(GetCommand()); addSubcommand(UpdateCommand()); addSubcommand(DeleteCommand()); diff --git a/packages/kanban/test/dew_kanban_test.dart b/packages/kanban/test/dew_kanban_test.dart index b8897fc..ea00a44 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', 'get', 'update', 'delete', + 'create', 'list', 'board', 'get', 'update', 'delete', 'move', 'search', 'comment', 'config', 'stats', 'link', 'unlink', ]), ); @@ -36,11 +36,12 @@ void main() { final registry = CommandRegistry(); registerCommands(registry); final tools = registry.mcpTools; - expect(tools, hasLength(12)); + expect(tools, hasLength(13)); final names = tools.map((t) => t.name).toSet(); expect(names, { 'kanban_create_ticket', 'kanban_list_tickets', + 'kanban_board', 'kanban_get_ticket', 'kanban_update_ticket', 'kanban_delete_ticket',