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:
parent
27a45e3d52
commit
5d8451383b
6 changed files with 109 additions and 3 deletions
|
|
@ -40,7 +40,23 @@ class MoveCommand extends DewCommand with DewToolCommand {
|
||||||
prefix: config.prefix,
|
prefix: config.prefix,
|
||||||
);
|
);
|
||||||
|
|
||||||
final ticket = await store.update(id, column: column);
|
final ticket = await store.findById(id);
|
||||||
return 'Moved ${ticket.id} to "$column".';
|
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".';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,16 @@ class ColumnConfig {
|
||||||
final String name;
|
final String name;
|
||||||
final String color;
|
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 {
|
class KanbanConfig {
|
||||||
|
|
@ -44,6 +53,10 @@ extension KanbanDewConfig on DewConfig {
|
||||||
id: c['id'] as String,
|
id: c['id'] as String,
|
||||||
name: c['name'] as String,
|
name: c['name'] as String,
|
||||||
color: c['color'] as String,
|
color: c['color'] as String,
|
||||||
|
allowedTransitions: (c['allowed_transitions'] as YamlList?)
|
||||||
|
?.map((t) => t as String)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
group('Ticket', () {
|
||||||
test('roundtrip serialisation', () {
|
test('roundtrip serialisation', () {
|
||||||
final t = Ticket(
|
final t = Ticket(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue