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,
|
||||
);
|
||||
|
||||
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".';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue