Column transition validation and enhanced search filters (DEW-0013, DEW-0014)

DEW-0013: Enhanced search filters (already implemented in DEW-0011 pass)
- dew kanban search already had --column/--type/--label/--milestone filters
- Closed ticket

DEW-0014: Column transition validation
- ColumnConfig gains optional allowedTransitions: List<String>
- Parsed from allowed_transitions YAML list on each column entry
- MoveCommand validates current column's allowedTransitions before moving;
  unconfigured (empty list) = all transitions allowed (existing behaviour)
- Test: transition validation integration test with constrained + unconstrained columns

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Chris Hendrickson 2026-04-23 20:05:55 -04:00
parent 27a45e3d52
commit 5d8451383b
6 changed files with 109 additions and 3 deletions

View file

@ -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".';
}
}

View file

@ -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<String> 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(),

View file

@ -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<ArgumentError>()),
);
// 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(