diff --git a/.project/kanban/backlog/DEW-0005.md b/.project/kanban/doing/DEW-0005.md similarity index 100% rename from .project/kanban/backlog/DEW-0005.md rename to .project/kanban/doing/DEW-0005.md diff --git a/.project/kanban/backlog/DEW-0013.md b/.project/kanban/done/DEW-0013.md similarity index 100% rename from .project/kanban/backlog/DEW-0013.md rename to .project/kanban/done/DEW-0013.md diff --git a/.project/kanban/backlog/DEW-0014.md b/.project/kanban/done/DEW-0014.md similarity index 100% rename from .project/kanban/backlog/DEW-0014.md rename to .project/kanban/done/DEW-0014.md diff --git a/packages/kanban/lib/src/commands/move_command.dart b/packages/kanban/lib/src/commands/move_command.dart index 65269a9..d08ecb4 100644 --- a/packages/kanban/lib/src/commands/move_command.dart +++ b/packages/kanban/lib/src/commands/move_command.dart @@ -40,7 +40,23 @@ class MoveCommand extends DewCommand with DewToolCommand { prefix: config.prefix, ); - final ticket = await store.update(id, column: column); - return 'Moved ${ticket.id} to "$column".'; + final ticket = await store.findById(id); + if (ticket == null) throw ArgumentError('Ticket $id not found.'); + + // Check allowed_transitions if configured on the current column. + final currentColConfig = config.columns.firstWhere( + (c) => c.id == ticket.column, + orElse: () => ColumnConfig(id: ticket.column, name: ticket.column, color: ''), + ); + if (currentColConfig.allowedTransitions.isNotEmpty && + !currentColConfig.allowedTransitions.contains(column)) { + throw ArgumentError( + 'Column "${ticket.column}" does not allow transitions to "$column". ' + 'Allowed: ${currentColConfig.allowedTransitions.join(', ')}', + ); + } + + final updated = await store.update(id, column: column); + return 'Moved ${updated.id} to "$column".'; } } diff --git a/packages/kanban/lib/src/kanban_config.dart b/packages/kanban/lib/src/kanban_config.dart index f6981a1..0f6ed00 100644 --- a/packages/kanban/lib/src/kanban_config.dart +++ b/packages/kanban/lib/src/kanban_config.dart @@ -13,7 +13,16 @@ class ColumnConfig { final String name; final String color; - const ColumnConfig({required this.id, required this.name, required this.color}); + /// Optional list of column IDs that this column can transition to. + /// If null/empty, all transitions are allowed. + final List allowedTransitions; + + const ColumnConfig({ + required this.id, + required this.name, + required this.color, + this.allowedTransitions = const [], + }); } class KanbanConfig { @@ -44,6 +53,10 @@ extension KanbanDewConfig on DewConfig { id: c['id'] as String, name: c['name'] as String, color: c['color'] as String, + allowedTransitions: (c['allowed_transitions'] as YamlList?) + ?.map((t) => t as String) + .toList() ?? + const [], ), ) .toList(), diff --git a/packages/kanban/test/dew_kanban_test.dart b/packages/kanban/test/dew_kanban_test.dart index ea00a44..a0f470f 100644 --- a/packages/kanban/test/dew_kanban_test.dart +++ b/packages/kanban/test/dew_kanban_test.dart @@ -129,6 +129,83 @@ dew: }); }); + group('MoveCommand transition validation', () { + test('move respects allowed_transitions when configured', () async { + final tempDir = await Directory.systemTemp.createTemp('kanban_transitions_test_'); + final origDir = Directory.current; + try { + await Directory(p.join(tempDir.path, '.project', 'kanban')).create(recursive: true); + await File(p.join(tempDir.path, '.project', 'dew.yaml')).writeAsString(''' +dew: + mcp: + host: localhost + port: 9090 + kanban: + prefix: T + ticket_types: + - id: task + name: Task + columns: + - id: backlog + name: Backlog + color: grey + allowed_transitions: + - doing + - id: doing + name: Doing + color: blue + allowed_transitions: + - done + - backlog + - id: done + name: Done + color: green +'''); + Directory.current = tempDir; + final registry = CommandRegistry(); + registerCommands(registry); + final tools = {for (final t in registry.mcpTools) t.name: t}; + + await tools['kanban_create_ticket']!.handler({ + 'title': 'Flow test', + 'type': 'task', + }); + + // Allowed: backlog → doing + await expectLater( + tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'}), + completes, + ); + + // Disallowed: doing → done is allowed, but doing → backlog is also + // allowed; skip to testing a rejected transition: + // "done" has no allowed_transitions (unconstrained), but let's test + // that "backlog" column can't go directly to "done". + // Reset to backlog first. + await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'backlog'}); + + // backlog → done should throw (not in allowed_transitions). + await expectLater( + tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'}), + throwsA(isA()), + ); + + // Unconstrained column (done) — any target is valid. + await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'}); + await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'}); + // done → backlog: done has no constraints, so it's allowed. + final result = await tools['kanban_move_ticket']!.handler({ + 'id': 'T-0001', + 'column': 'backlog', + }); + expect(result, contains('T-0001')); + } finally { + Directory.current = origDir; + await tempDir.delete(recursive: true); + } + }); + }); + group('Ticket', () { test('roundtrip serialisation', () { final t = Ticket(