dew/packages/kanban/lib/src/ticket.dart
Chris Hendrickson 4efa1078ea Add stats, move, link, and unlink kanban tools
- Add links field to Ticket model (YAML frontmatter list, roundtrips
  correctly; omitted from file when empty)
- Add TicketStore.linkTickets(), unlinkTickets(), and stats() methods
- Add StatsCommand (kanban_stats) — ticket counts by column and type
- Add MoveCommand (kanban_move_ticket) — validated column transition
- Add LinkCommand (kanban_link_tickets) — track ticket dependencies
- Add UnlinkCommand (kanban_unlink_tickets) — remove ticket links
- Register all 4 new subcommands in KanbanCommand (12 total)
- All 27 tests pass, dart analyze clean

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 17:10:09 -04:00

115 lines
2.8 KiB
Dart

import 'package:yaml/yaml.dart';
class Ticket {
final String id;
final String title;
final String type;
final String column;
final DateTime created;
final String body;
final List<String> comments;
/// IDs of tickets this ticket is linked to (e.g. dependencies).
final List<String> links;
const Ticket({
required this.id,
required this.title,
required this.type,
required this.column,
required this.created,
required this.body,
required this.comments,
this.links = const [],
});
Ticket copyWith({
String? title,
String? type,
String? column,
String? body,
List<String>? comments,
List<String>? links,
}) => Ticket(
id: id,
title: title ?? this.title,
type: type ?? this.type,
column: column ?? this.column,
created: created,
body: body ?? this.body,
comments: comments ?? this.comments,
links: links ?? this.links,
);
/// Serialises the ticket to markdown with YAML frontmatter.
///
/// Format:
/// ```
/// ---
/// id: DEW-0001
/// title: My ticket
/// ...
/// ---
///
/// Body text.
///
/// ---
///
/// Comment text.
/// ```
String toFileContent() {
final buf = StringBuffer();
buf.writeln('---');
buf.writeln('id: $id');
buf.writeln('title: $title');
buf.writeln('type: $type');
buf.writeln('column: $column');
buf.writeln('created: ${created.toUtc().toIso8601String()}');
if (links.isNotEmpty) {
buf.writeln('links:');
for (final link in links) {
buf.writeln(' - $link');
}
}
buf.writeln('---');
if (body.isNotEmpty) {
buf.writeln();
buf.writeln(body);
}
for (final comment in comments) {
buf.writeln();
buf.writeln('---');
buf.writeln();
buf.writeln(comment);
}
return buf.toString();
}
static Ticket fromFileContent(String id, String content) {
if (!content.startsWith('---\n')) {
throw FormatException('Ticket file $id does not start with ---');
}
final fmEnd = content.indexOf('\n---\n', 4);
if (fmEnd == -1) {
throw FormatException('Ticket file $id is missing closing frontmatter ---');
}
final fm = loadYaml(content.substring(4, fmEnd)) as YamlMap;
// Everything after the closing \n---\n, split into body + comments.
final rest = content.substring(fmEnd + 5);
final sections =
rest.split('\n---\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
return Ticket(
id: id,
title: fm['title'] as String,
type: fm['type'] as String,
column: fm['column'] as String,
created: DateTime.parse(fm['created'] as String),
body: sections.isNotEmpty ? sections[0] : '',
comments: sections.length > 1 ? sections.sublist(1) : const [],
links: (fm['links'] as YamlList?)?.cast<String>().toList() ?? const [],
);
}
}