diff --git a/.project/kanban/backlog/DEW-0003.md b/.project/kanban/done/DEW-0003.md similarity index 100% rename from .project/kanban/backlog/DEW-0003.md rename to .project/kanban/done/DEW-0003.md diff --git a/.project/kanban/backlog/DEW-0011.md b/.project/kanban/done/DEW-0011.md similarity index 100% rename from .project/kanban/backlog/DEW-0011.md rename to .project/kanban/done/DEW-0011.md diff --git a/packages/kanban/lib/src/commands/create_command.dart b/packages/kanban/lib/src/commands/create_command.dart index efd744b..89145fe 100644 --- a/packages/kanban/lib/src/commands/create_command.dart +++ b/packages/kanban/lib/src/commands/create_command.dart @@ -18,7 +18,12 @@ class CreateCommand extends DewCommand with DewToolCommand { abbr: 'c', help: 'Initial column. Defaults to the first configured column.', ) - ..addOption('body', abbr: 'b', help: 'Ticket description.'); + ..addOption('body', abbr: 'b', help: 'Ticket description.') + ..addMultiOption( + 'milestone', + help: 'Milestone(s) to assign (repeatable).', + ) + ..addMultiOption('label', help: 'Label(s) to assign (repeatable).'); } @override @@ -39,6 +44,8 @@ class CreateCommand extends DewCommand with DewToolCommand { final typeId = args['type'] as String; final columnArg = args['column'] as String?; final body = args['body'] as String? ?? ''; + final milestones = _toStringList(args['milestone']); + final labels = _toStringList(args['label']); if (!config.ticketTypes.any((t) => t.id == typeId)) { throw ArgumentError( @@ -64,7 +71,15 @@ class CreateCommand extends DewCommand with DewToolCommand { type: typeId, column: column, body: body, + milestones: milestones, + labels: labels, ); return 'Created ${ticket.id}: ${ticket.title}'; } + + static List _toStringList(dynamic value) { + if (value is List) return value.cast(); + if (value is String && value.isNotEmpty) return [value]; + return const []; + } } diff --git a/packages/kanban/lib/src/commands/list_command.dart b/packages/kanban/lib/src/commands/list_command.dart index 15a62ff..5b44f0b 100644 --- a/packages/kanban/lib/src/commands/list_command.dart +++ b/packages/kanban/lib/src/commands/list_command.dart @@ -8,7 +8,9 @@ class ListCommand extends DewCommand with DewToolCommand { ListCommand() { argParser ..addOption('column', abbr: 'c', help: 'Filter to tickets in this column.') - ..addOption('type', abbr: 't', help: 'Filter to tickets of this type.'); + ..addOption('type', abbr: 't', help: 'Filter to tickets of this type.') + ..addOption('label', help: 'Filter to tickets with this label.') + ..addOption('milestone', help: 'Filter to tickets in this milestone.'); } @override @@ -24,6 +26,8 @@ class ListCommand extends DewCommand with DewToolCommand { Future callAsTool(Map args) async { final columnFilter = args['column'] as String?; final typeFilter = args['type'] as String?; + final labelFilter = args['label'] as String?; + final milestoneFilter = args['milestone'] as String?; final context = await ProjectContext.find(); final store = TicketStore( @@ -38,6 +42,12 @@ class ListCommand extends DewCommand with DewToolCommand { 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(); + } if (tickets.isEmpty) return 'No tickets found.'; return tickets diff --git a/packages/kanban/lib/src/commands/search_command.dart b/packages/kanban/lib/src/commands/search_command.dart index 054cdd4..f8b478d 100644 --- a/packages/kanban/lib/src/commands/search_command.dart +++ b/packages/kanban/lib/src/commands/search_command.dart @@ -14,7 +14,9 @@ class SearchCommand extends DewCommand with DewToolCommand { help: 'Search query (matches title, body, and comments).', ) ..addOption('column', abbr: 'c', help: 'Restrict search to this column.') - ..addOption('type', abbr: 't', help: 'Restrict search to this ticket type.'); + ..addOption('type', abbr: 't', help: 'Restrict search to this ticket type.') + ..addOption('label', help: 'Restrict search to tickets with this label.') + ..addOption('milestone', help: 'Restrict search to tickets in this milestone.'); } @override @@ -31,6 +33,8 @@ class SearchCommand extends DewCommand with DewToolCommand { final query = (args['query'] as String).toLowerCase(); final columnFilter = args['column'] as String?; final typeFilter = args['type'] as String?; + final labelFilter = args['label'] as String?; + final milestoneFilter = args['milestone'] as String?; final context = await ProjectContext.find(); final store = TicketStore( @@ -45,6 +49,12 @@ class SearchCommand extends DewCommand with DewToolCommand { 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(); + } final matches = tickets.where((t) { return t.title.toLowerCase().contains(query) || diff --git a/packages/kanban/lib/src/commands/update_command.dart b/packages/kanban/lib/src/commands/update_command.dart index a7c2418..7d03d61 100644 --- a/packages/kanban/lib/src/commands/update_command.dart +++ b/packages/kanban/lib/src/commands/update_command.dart @@ -11,7 +11,15 @@ class UpdateCommand extends DewCommand with DewToolCommand { ..addOption('title', abbr: 't', help: 'New title.') ..addOption('type', help: 'New ticket type.') ..addOption('column', abbr: 'c', help: 'New column.') - ..addOption('body', abbr: 'b', help: 'New body (replaces existing body).'); + ..addOption('body', abbr: 'b', help: 'New body (replaces existing body).') + ..addMultiOption( + 'milestone', + help: 'Replace milestone list (repeatable; omit to leave unchanged).', + ) + ..addMultiOption( + 'label', + help: 'Replace label list (repeatable; omit to leave unchanged).', + ); } @override @@ -30,9 +38,23 @@ class UpdateCommand extends DewCommand with DewToolCommand { final typeId = args['type'] as String?; final column = args['column'] as String?; final body = args['body'] as String?; + final rawMilestones = args['milestone'] as List?; + final milestones = rawMilestones != null && rawMilestones.isNotEmpty + ? rawMilestones.cast() + : null; + final rawLabels = args['label'] as List?; + final labels = + rawLabels != null && rawLabels.isNotEmpty ? rawLabels.cast() : null; - if (title == null && typeId == null && column == null && body == null) { - throw ArgumentError('At least one of --title, --type, --column, --body must be specified.'); + if (title == null && + typeId == null && + column == null && + body == null && + milestones == null && + labels == null) { + throw ArgumentError( + 'At least one of --title, --type, --column, --body, --milestone, --label must be specified.', + ); } final context = await ProjectContext.find(); @@ -55,7 +77,15 @@ class UpdateCommand extends DewCommand with DewToolCommand { kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: config.prefix, ); - final ticket = await store.update(id, title: title, type: typeId, column: column, body: body); + final ticket = await store.update( + id, + title: title, + type: typeId, + column: column, + body: body, + milestones: milestones, + labels: labels, + ); return 'Updated ${ticket.id}.'; } } diff --git a/packages/kanban/lib/src/ticket.dart b/packages/kanban/lib/src/ticket.dart index b7095a1..7a51072 100644 --- a/packages/kanban/lib/src/ticket.dart +++ b/packages/kanban/lib/src/ticket.dart @@ -36,9 +36,9 @@ class Ticket { final DateTime created; final String body; final List comments; - - /// Typed links to other tickets. final List links; + final List milestones; + final List labels; const Ticket({ required this.id, @@ -49,6 +49,8 @@ class Ticket { required this.body, required this.comments, this.links = const [], + this.milestones = const [], + this.labels = const [], }); Ticket copyWith({ @@ -58,6 +60,8 @@ class Ticket { String? body, List? comments, List? links, + List? milestones, + List? labels, }) => Ticket( id: id, title: title ?? this.title, @@ -67,6 +71,8 @@ class Ticket { body: body ?? this.body, comments: comments ?? this.comments, links: links ?? this.links, + milestones: milestones ?? this.milestones, + labels: labels ?? this.labels, ); /// Serialises the ticket to markdown with YAML frontmatter. @@ -95,6 +101,18 @@ class Ticket { buf.writeln('title: ${_yamlQuote(title)}'); buf.writeln('type: $type'); buf.writeln('created: ${created.toUtc().toIso8601String()}'); + if (milestones.isNotEmpty) { + buf.writeln('milestones:'); + for (final m in milestones) { + buf.writeln(' - ${_yamlQuote(m)}'); + } + } + if (labels.isNotEmpty) { + buf.writeln('labels:'); + for (final l in labels) { + buf.writeln(' - ${_yamlQuote(l)}'); + } + } if (links.isNotEmpty) { buf.writeln('links:'); for (final link in links) { @@ -146,6 +164,11 @@ class Ticket { .toList() ?? const []; + List parseStringList(String key) { + final raw = fm[key] as YamlList?; + return raw?.map((e) => e as String).toList() ?? const []; + } + return Ticket( id: id, title: fm['title'] as String, @@ -155,6 +178,8 @@ class Ticket { body: sections.isNotEmpty ? sections[0] : '', comments: sections.length > 1 ? sections.sublist(1) : const [], links: links, + milestones: parseStringList('milestones'), + labels: parseStringList('labels'), ); } diff --git a/packages/kanban/lib/src/ticket_store.dart b/packages/kanban/lib/src/ticket_store.dart index 65fcd18..f87c30f 100644 --- a/packages/kanban/lib/src/ticket_store.dart +++ b/packages/kanban/lib/src/ticket_store.dart @@ -15,6 +15,8 @@ class TicketStore { required String type, required String column, String body = '', + List milestones = const [], + List labels = const [], }) async { final columnDir = Directory(p.join(kanbanDir, column)); await columnDir.create(recursive: true); @@ -27,6 +29,8 @@ class TicketStore { created: DateTime.now().toUtc(), body: body, comments: const [], + milestones: milestones, + labels: labels, ); await File(p.join(columnDir.path, '$id.md')).writeAsString(ticket.toFileContent()); return ticket; @@ -146,11 +150,20 @@ class TicketStore { String? type, String? column, String? body, + List? milestones, + List? labels, }) async { final found = await _findTicketFile(id); if (found == null) throw ArgumentError('Ticket $id not found.'); final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column); - final updated = ticket.copyWith(title: title, type: type, column: column, body: body); + final updated = ticket.copyWith( + title: title, + type: type, + column: column, + body: body, + milestones: milestones, + labels: labels, + ); if (column != null && column != ticket.column) { // Column changed — move the file to the new column directory. await found.file.delete(); diff --git a/packages/kanban/test/dew_kanban_test.dart b/packages/kanban/test/dew_kanban_test.dart index c819032..b8897fc 100644 --- a/packages/kanban/test/dew_kanban_test.dart +++ b/packages/kanban/test/dew_kanban_test.dart @@ -198,6 +198,38 @@ dew: ); expect(t.toFileContent(), isNot(contains('links:'))); }); + + test('milestones and labels roundtrip serialisation', () { + final t = Ticket( + id: 'TEST-0005', + title: 'Tagged', + type: 'task', + column: 'todo', + created: DateTime.utc(2026, 1, 5), + body: '', + comments: const [], + milestones: ['v1.0', 'beta'], + labels: ['good first issue', 'backend'], + ); + final parsed = Ticket.fromFileContent(t.id, t.toFileContent(), t.column); + expect(parsed.milestones, ['v1.0', 'beta']); + expect(parsed.labels, ['good first issue', 'backend']); + }); + + test('no milestones/labels fields when empty', () { + final t = Ticket( + id: 'TEST-0006', + title: 'Plain', + type: 'task', + column: 'todo', + created: DateTime.utc(2026, 1, 6), + body: '', + comments: const [], + ); + final content = t.toFileContent(); + expect(content, isNot(contains('milestones:'))); + expect(content, isNot(contains('labels:'))); + }); }); group('TicketStore', () { @@ -235,6 +267,37 @@ dew: expect(await store.findById('TEST-0099'), isNull); }); + test('create and list milestones/labels persist via store', () async { + final store = makeStore(); + await store.create( + title: 'Tagged', + type: 'task', + column: 'todo', + milestones: ['v1.0'], + labels: ['enhancement'], + ); + await store.create(title: 'Plain', type: 'task', column: 'todo'); + final all = await store.list(); + expect(all[0].milestones, ['v1.0']); + expect(all[0].labels, ['enhancement']); + expect(all[1].milestones, isEmpty); + expect(all[1].labels, isEmpty); + }); + + test('update patches milestones and labels', () async { + final store = makeStore(); + await store.create( + title: 'Tagged', + type: 'task', + column: 'todo', + milestones: ['v1.0'], + labels: ['old'], + ); + final updated = await store.update('TEST-0001', labels: ['new']); + expect(updated.milestones, ['v1.0']); + expect(updated.labels, ['new']); + }); + test('list returns sorted tickets', () async { final store = makeStore(); await store.create(title: 'A', type: 'task', column: 'todo');