Add milestones and labels to Ticket model (DEW-0011)

- Ticket.milestones and Ticket.labels: List<String>, optional (default [])
- toFileContent() emits milestones:/labels: YAML lists when non-empty
- fromFileContent() parses them back; empty when absent (backwards compat)
- TicketStore.create() and update() accept milestones/labels params
- CreateCommand and UpdateCommand: --milestone/--label multi-options
- ListCommand and SearchCommand: --milestone/--label filter options
- 4 new tests: model roundtrip, store persistence, update patch, no fields when empty

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Chris Hendrickson 2026-04-23 20:01:47 -04:00
parent 31f9ba4726
commit f89b3aa998
9 changed files with 176 additions and 10 deletions

View file

@ -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<String> _toStringList(dynamic value) {
if (value is List) return value.cast<String>();
if (value is String && value.isNotEmpty) return [value];
return const [];
}
}

View file

@ -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<String> callAsTool(Map<String, dynamic> 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

View file

@ -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) ||

View file

@ -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<String>()
: null;
final rawLabels = args['label'] as List?;
final labels =
rawLabels != null && rawLabels.isNotEmpty ? rawLabels.cast<String>() : 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}.';
}
}

View file

@ -36,9 +36,9 @@ class Ticket {
final DateTime created;
final String body;
final List<String> comments;
/// Typed links to other tickets.
final List<TicketLink> links;
final List<String> milestones;
final List<String> 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<String>? comments,
List<TicketLink>? links,
List<String>? milestones,
List<String>? 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<String> 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'),
);
}

View file

@ -15,6 +15,8 @@ class TicketStore {
required String type,
required String column,
String body = '',
List<String> milestones = const [],
List<String> 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<String>? milestones,
List<String>? 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();

View file

@ -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');