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:
parent
31f9ba4726
commit
f89b3aa998
9 changed files with 176 additions and 10 deletions
|
|
@ -18,7 +18,12 @@ class CreateCommand extends DewCommand with DewToolCommand {
|
||||||
abbr: 'c',
|
abbr: 'c',
|
||||||
help: 'Initial column. Defaults to the first configured column.',
|
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
|
@override
|
||||||
|
|
@ -39,6 +44,8 @@ class CreateCommand extends DewCommand with DewToolCommand {
|
||||||
final typeId = args['type'] as String;
|
final typeId = args['type'] as String;
|
||||||
final columnArg = args['column'] as String?;
|
final columnArg = args['column'] as String?;
|
||||||
final body = args['body'] 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)) {
|
if (!config.ticketTypes.any((t) => t.id == typeId)) {
|
||||||
throw ArgumentError(
|
throw ArgumentError(
|
||||||
|
|
@ -64,7 +71,15 @@ class CreateCommand extends DewCommand with DewToolCommand {
|
||||||
type: typeId,
|
type: typeId,
|
||||||
column: column,
|
column: column,
|
||||||
body: body,
|
body: body,
|
||||||
|
milestones: milestones,
|
||||||
|
labels: labels,
|
||||||
);
|
);
|
||||||
return 'Created ${ticket.id}: ${ticket.title}';
|
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 [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ class ListCommand extends DewCommand with DewToolCommand {
|
||||||
ListCommand() {
|
ListCommand() {
|
||||||
argParser
|
argParser
|
||||||
..addOption('column', abbr: 'c', help: 'Filter to tickets in this column.')
|
..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
|
@override
|
||||||
|
|
@ -24,6 +26,8 @@ class ListCommand extends DewCommand with DewToolCommand {
|
||||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
final columnFilter = args['column'] as String?;
|
final columnFilter = args['column'] as String?;
|
||||||
final typeFilter = args['type'] 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 context = await ProjectContext.find();
|
||||||
final store = TicketStore(
|
final store = TicketStore(
|
||||||
|
|
@ -38,6 +42,12 @@ class ListCommand extends DewCommand with DewToolCommand {
|
||||||
if (typeFilter != null) {
|
if (typeFilter != null) {
|
||||||
tickets = tickets.where((t) => t.type == typeFilter).toList();
|
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.';
|
if (tickets.isEmpty) return 'No tickets found.';
|
||||||
return tickets
|
return tickets
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ class SearchCommand extends DewCommand with DewToolCommand {
|
||||||
help: 'Search query (matches title, body, and comments).',
|
help: 'Search query (matches title, body, and comments).',
|
||||||
)
|
)
|
||||||
..addOption('column', abbr: 'c', help: 'Restrict search to this column.')
|
..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
|
@override
|
||||||
|
|
@ -31,6 +33,8 @@ class SearchCommand extends DewCommand with DewToolCommand {
|
||||||
final query = (args['query'] as String).toLowerCase();
|
final query = (args['query'] as String).toLowerCase();
|
||||||
final columnFilter = args['column'] as String?;
|
final columnFilter = args['column'] as String?;
|
||||||
final typeFilter = args['type'] 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 context = await ProjectContext.find();
|
||||||
final store = TicketStore(
|
final store = TicketStore(
|
||||||
|
|
@ -45,6 +49,12 @@ class SearchCommand extends DewCommand with DewToolCommand {
|
||||||
if (typeFilter != null) {
|
if (typeFilter != null) {
|
||||||
tickets = tickets.where((t) => t.type == typeFilter).toList();
|
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) {
|
final matches = tickets.where((t) {
|
||||||
return t.title.toLowerCase().contains(query) ||
|
return t.title.toLowerCase().contains(query) ||
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,15 @@ class UpdateCommand extends DewCommand with DewToolCommand {
|
||||||
..addOption('title', abbr: 't', help: 'New title.')
|
..addOption('title', abbr: 't', help: 'New title.')
|
||||||
..addOption('type', help: 'New ticket type.')
|
..addOption('type', help: 'New ticket type.')
|
||||||
..addOption('column', abbr: 'c', help: 'New column.')
|
..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
|
@override
|
||||||
|
|
@ -30,9 +38,23 @@ class UpdateCommand extends DewCommand with DewToolCommand {
|
||||||
final typeId = args['type'] as String?;
|
final typeId = args['type'] as String?;
|
||||||
final column = args['column'] as String?;
|
final column = args['column'] as String?;
|
||||||
final body = args['body'] 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) {
|
if (title == null &&
|
||||||
throw ArgumentError('At least one of --title, --type, --column, --body must be specified.');
|
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();
|
final context = await ProjectContext.find();
|
||||||
|
|
@ -55,7 +77,15 @@ class UpdateCommand extends DewCommand with DewToolCommand {
|
||||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||||
prefix: config.prefix,
|
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}.';
|
return 'Updated ${ticket.id}.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,9 @@ class Ticket {
|
||||||
final DateTime created;
|
final DateTime created;
|
||||||
final String body;
|
final String body;
|
||||||
final List<String> comments;
|
final List<String> comments;
|
||||||
|
|
||||||
/// Typed links to other tickets.
|
|
||||||
final List<TicketLink> links;
|
final List<TicketLink> links;
|
||||||
|
final List<String> milestones;
|
||||||
|
final List<String> labels;
|
||||||
|
|
||||||
const Ticket({
|
const Ticket({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -49,6 +49,8 @@ class Ticket {
|
||||||
required this.body,
|
required this.body,
|
||||||
required this.comments,
|
required this.comments,
|
||||||
this.links = const [],
|
this.links = const [],
|
||||||
|
this.milestones = const [],
|
||||||
|
this.labels = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
Ticket copyWith({
|
Ticket copyWith({
|
||||||
|
|
@ -58,6 +60,8 @@ class Ticket {
|
||||||
String? body,
|
String? body,
|
||||||
List<String>? comments,
|
List<String>? comments,
|
||||||
List<TicketLink>? links,
|
List<TicketLink>? links,
|
||||||
|
List<String>? milestones,
|
||||||
|
List<String>? labels,
|
||||||
}) => Ticket(
|
}) => Ticket(
|
||||||
id: id,
|
id: id,
|
||||||
title: title ?? this.title,
|
title: title ?? this.title,
|
||||||
|
|
@ -67,6 +71,8 @@ class Ticket {
|
||||||
body: body ?? this.body,
|
body: body ?? this.body,
|
||||||
comments: comments ?? this.comments,
|
comments: comments ?? this.comments,
|
||||||
links: links ?? this.links,
|
links: links ?? this.links,
|
||||||
|
milestones: milestones ?? this.milestones,
|
||||||
|
labels: labels ?? this.labels,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Serialises the ticket to markdown with YAML frontmatter.
|
/// Serialises the ticket to markdown with YAML frontmatter.
|
||||||
|
|
@ -95,6 +101,18 @@ class Ticket {
|
||||||
buf.writeln('title: ${_yamlQuote(title)}');
|
buf.writeln('title: ${_yamlQuote(title)}');
|
||||||
buf.writeln('type: $type');
|
buf.writeln('type: $type');
|
||||||
buf.writeln('created: ${created.toUtc().toIso8601String()}');
|
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) {
|
if (links.isNotEmpty) {
|
||||||
buf.writeln('links:');
|
buf.writeln('links:');
|
||||||
for (final link in links) {
|
for (final link in links) {
|
||||||
|
|
@ -146,6 +164,11 @@ class Ticket {
|
||||||
.toList() ??
|
.toList() ??
|
||||||
const [];
|
const [];
|
||||||
|
|
||||||
|
List<String> parseStringList(String key) {
|
||||||
|
final raw = fm[key] as YamlList?;
|
||||||
|
return raw?.map((e) => e as String).toList() ?? const [];
|
||||||
|
}
|
||||||
|
|
||||||
return Ticket(
|
return Ticket(
|
||||||
id: id,
|
id: id,
|
||||||
title: fm['title'] as String,
|
title: fm['title'] as String,
|
||||||
|
|
@ -155,6 +178,8 @@ class Ticket {
|
||||||
body: sections.isNotEmpty ? sections[0] : '',
|
body: sections.isNotEmpty ? sections[0] : '',
|
||||||
comments: sections.length > 1 ? sections.sublist(1) : const [],
|
comments: sections.length > 1 ? sections.sublist(1) : const [],
|
||||||
links: links,
|
links: links,
|
||||||
|
milestones: parseStringList('milestones'),
|
||||||
|
labels: parseStringList('labels'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ class TicketStore {
|
||||||
required String type,
|
required String type,
|
||||||
required String column,
|
required String column,
|
||||||
String body = '',
|
String body = '',
|
||||||
|
List<String> milestones = const [],
|
||||||
|
List<String> labels = const [],
|
||||||
}) async {
|
}) async {
|
||||||
final columnDir = Directory(p.join(kanbanDir, column));
|
final columnDir = Directory(p.join(kanbanDir, column));
|
||||||
await columnDir.create(recursive: true);
|
await columnDir.create(recursive: true);
|
||||||
|
|
@ -27,6 +29,8 @@ class TicketStore {
|
||||||
created: DateTime.now().toUtc(),
|
created: DateTime.now().toUtc(),
|
||||||
body: body,
|
body: body,
|
||||||
comments: const [],
|
comments: const [],
|
||||||
|
milestones: milestones,
|
||||||
|
labels: labels,
|
||||||
);
|
);
|
||||||
await File(p.join(columnDir.path, '$id.md')).writeAsString(ticket.toFileContent());
|
await File(p.join(columnDir.path, '$id.md')).writeAsString(ticket.toFileContent());
|
||||||
return ticket;
|
return ticket;
|
||||||
|
|
@ -146,11 +150,20 @@ class TicketStore {
|
||||||
String? type,
|
String? type,
|
||||||
String? column,
|
String? column,
|
||||||
String? body,
|
String? body,
|
||||||
|
List<String>? milestones,
|
||||||
|
List<String>? labels,
|
||||||
}) async {
|
}) async {
|
||||||
final found = await _findTicketFile(id);
|
final found = await _findTicketFile(id);
|
||||||
if (found == null) throw ArgumentError('Ticket $id not found.');
|
if (found == null) throw ArgumentError('Ticket $id not found.');
|
||||||
final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column);
|
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) {
|
if (column != null && column != ticket.column) {
|
||||||
// Column changed — move the file to the new column directory.
|
// Column changed — move the file to the new column directory.
|
||||||
await found.file.delete();
|
await found.file.delete();
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,38 @@ dew:
|
||||||
);
|
);
|
||||||
expect(t.toFileContent(), isNot(contains('links:')));
|
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', () {
|
group('TicketStore', () {
|
||||||
|
|
@ -235,6 +267,37 @@ dew:
|
||||||
expect(await store.findById('TEST-0099'), isNull);
|
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 {
|
test('list returns sorted tickets', () async {
|
||||||
final store = makeStore();
|
final store = makeStore();
|
||||||
await store.create(title: 'A', type: 'task', column: 'todo');
|
await store.create(title: 'A', type: 'task', column: 'todo');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue