Implement kanban subcommands (create, get, update, delete)

- Add ProjectContext and DewConfig models to core
- Add Ticket model with YAML frontmatter serialisation
- Add TicketStore with auto-incrementing 4-digit IDs
- Implement create/get/update/delete subcommands under KanbanCommand
- Expand tests: ProjectContext, Ticket roundtrip, TicketStore CRUD (19 total)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Chris Hendrickson 2026-04-23 14:47:25 -04:00
parent 0723c7d996
commit a1c36f7136
14 changed files with 734 additions and 3 deletions

View file

@ -1,3 +1,4 @@
library;
export 'src/config.dart';
export 'src/dew_core_base.dart';

View file

@ -0,0 +1,120 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart';
class TicketTypeConfig {
final String id;
final String name;
const TicketTypeConfig({required this.id, required this.name});
}
class ColumnConfig {
final String id;
final String name;
final String color;
const ColumnConfig({
required this.id,
required this.name,
required this.color,
});
}
class KanbanConfig {
final String prefix;
final List<TicketTypeConfig> ticketTypes;
final List<ColumnConfig> columns;
const KanbanConfig({
required this.prefix,
required this.ticketTypes,
required this.columns,
});
}
class McpConfig {
final String host;
final int port;
const McpConfig({required this.host, required this.port});
}
class DewConfig {
final KanbanConfig kanban;
final McpConfig mcp;
const DewConfig({required this.kanban, required this.mcp});
factory DewConfig.fromYaml(YamlMap yaml) {
final dew = yaml['dew'] as YamlMap;
final mcpYaml = dew['mcp'] as YamlMap;
final mcp = McpConfig(
host: mcpYaml['host'] as String,
port: mcpYaml['port'] as int,
);
final kanbanYaml = dew['kanban'] as YamlMap;
final ticketTypes =
(kanbanYaml['ticket_types'] as YamlList)
.map(
(t) => TicketTypeConfig(
id: t['id'] as String,
name: t['name'] as String,
),
)
.toList();
final columns =
(kanbanYaml['columns'] as YamlList)
.map(
(c) => ColumnConfig(
id: c['id'] as String,
name: c['name'] as String,
color: c['color'] as String,
),
)
.toList();
return DewConfig(
kanban: KanbanConfig(
prefix: kanbanYaml['prefix'] as String,
ticketTypes: ticketTypes,
columns: columns,
),
mcp: mcp,
);
}
}
/// Locates the nearest project root and exposes the parsed [DewConfig].
class ProjectContext {
final String root;
final DewConfig config;
const ProjectContext({required this.root, required this.config});
/// Walks up from [Directory.current] until a `.project/dew.yaml` is found.
static Future<ProjectContext> find() async {
var dir = Directory.current;
while (true) {
final configFile = File(p.join(dir.path, '.project', 'dew.yaml'));
if (await configFile.exists()) {
final yaml = loadYaml(await configFile.readAsString()) as YamlMap;
return ProjectContext(
root: dir.path,
config: DewConfig.fromYaml(yaml),
);
}
final parent = dir.parent;
if (parent.path == dir.path) {
throw StateError(
'Could not find .project/dew.yaml. '
'Run "dew init ." to initialise a project here.',
);
}
dir = parent;
}
}
}

View file

@ -12,6 +12,7 @@ environment:
dependencies:
args: ^2.7.0
path: ^1.9.0
yaml: ^3.1.0
dev_dependencies:
lints: ^6.0.0

View file

@ -1,7 +1,9 @@
import 'dart:io';
import 'package:dew_core/dew_core.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
// Minimal concrete command for testing the registry.
class _TestCommand extends DewCommand {
@override
final String name = 'test-cmd';
@ -27,7 +29,60 @@ void main() {
test('commands list is unmodifiable', () {
final registry = CommandRegistry();
expect(() => registry.commands.add(_TestCommand()), throwsUnsupportedError);
expect(
() => registry.commands.add(_TestCommand()),
throwsUnsupportedError,
);
});
});
group('ProjectContext', () {
late Directory tempDir;
late Directory originalDir;
setUp(() async {
originalDir = Directory.current;
tempDir = await Directory.systemTemp.createTemp('dew_core_test_');
await Directory(p.join(tempDir.path, '.project')).create();
await File(
p.join(tempDir.path, '.project', 'dew.yaml'),
).writeAsString('''
dew:
mcp:
host: localhost
port: 9090
kanban:
prefix: TEST
ticket_types:
- id: task
name: Task
columns:
- id: todo
name: To Do
color: blue
''');
Directory.current = tempDir;
});
tearDown(() async {
Directory.current = originalDir;
await tempDir.delete(recursive: true);
});
test('find() loads config from .project/dew.yaml', () async {
final ctx = await ProjectContext.find();
expect(ctx.config.kanban.prefix, 'TEST');
expect(ctx.config.kanban.ticketTypes, hasLength(1));
expect(ctx.config.kanban.columns.first.id, 'todo');
expect(ctx.config.mcp.host, 'localhost');
expect(ctx.config.mcp.port, 9090);
});
test('find() locates config from a subdirectory', () async {
final sub = await Directory(p.join(tempDir.path, 'sub')).create();
Directory.current = sub;
final ctx = await ProjectContext.find();
expect(ctx.root, tempDir.path);
});
});
}

View file

@ -1,6 +1,8 @@
library;
export 'src/dew_kanban_base.dart';
export 'src/ticket.dart';
export 'src/ticket_store.dart';
import 'package:dew_core/dew_core.dart';
import 'package:dew_kanban/src/dew_kanban_base.dart';

View file

@ -0,0 +1,68 @@
import 'package:dew_core/dew_core.dart';
import 'package:path/path.dart' as p;
import '../ticket_store.dart';
class CreateCommand extends DewCommand {
CreateCommand() {
argParser
..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.')
..addOption(
'type',
mandatory: true,
help: 'Ticket type (e.g. task, bug).',
)
..addOption(
'column',
abbr: 'c',
help: 'Initial column. Defaults to the first configured column.',
)
..addOption('body', abbr: 'b', help: 'Ticket description.');
}
@override
final String name = 'create';
@override
final String description = 'Create a new kanban ticket.';
@override
Future<void> run() async {
final context = await ProjectContext.find();
final config = context.config.kanban;
final title = argResults!['title'] as String;
final typeId = argResults!['type'] as String;
final columnArg = argResults!['column'] as String?;
final body = argResults!['body'] as String? ?? '';
if (!config.ticketTypes.any((t) => t.id == typeId)) {
usageException(
'Unknown type "$typeId". '
'Valid types: ${config.ticketTypes.map((t) => t.id).join(', ')}',
);
}
final column = columnArg ?? config.columns.first.id;
if (!config.columns.any((c) => c.id == column)) {
usageException(
'Unknown column "$column". '
'Valid columns: ${config.columns.map((c) => c.id).join(', ')}',
);
}
final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: config.prefix,
);
final ticket = await store.create(
title: title,
type: typeId,
column: column,
body: body,
);
print('Created ${ticket.id}.');
}
}

View file

@ -0,0 +1,32 @@
import 'package:dew_core/dew_core.dart';
import 'package:path/path.dart' as p;
import '../ticket_store.dart';
class DeleteCommand extends DewCommand {
@override
final String name = 'delete';
@override
final String description = 'Delete a kanban ticket.';
@override
Future<void> run() async {
final rest = argResults!.rest;
if (rest.isEmpty) usageException('Ticket ID is required.');
final id = rest.first.toUpperCase();
final context = await ProjectContext.find();
final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: context.config.kanban.prefix,
);
try {
await store.delete(id);
print('Deleted $id.');
} on ArgumentError catch (e) {
usageException(e.message as String);
}
}
}

View file

@ -0,0 +1,42 @@
import 'package:dew_core/dew_core.dart';
import 'package:path/path.dart' as p;
import '../ticket_store.dart';
class GetCommand extends DewCommand {
@override
final String name = 'get';
@override
final String description = 'Get a kanban ticket by ID.';
@override
Future<void> run() async {
final rest = argResults!.rest;
if (rest.isEmpty) usageException('Ticket ID is required.');
final id = rest.first.toUpperCase();
final context = await ProjectContext.find();
final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: context.config.kanban.prefix,
);
final ticket = await store.findById(id);
if (ticket == null) usageException('Ticket $id not found.');
print('[${ticket.id}] (${ticket.type}) [${ticket.column}] ${ticket.title}');
print('Created: ${ticket.created.toLocal().toString().split('.').first}');
if (ticket.body.isNotEmpty) {
print('');
print(ticket.body);
}
for (final (i, comment) in ticket.comments.indexed) {
print('');
print('── Comment ${i + 1} ${'' * 20}');
print(comment);
}
}
}

View file

@ -0,0 +1,71 @@
import 'package:dew_core/dew_core.dart';
import 'package:path/path.dart' as p;
import '../ticket_store.dart';
class UpdateCommand extends DewCommand {
UpdateCommand() {
argParser
..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).');
}
@override
final String name = 'update';
@override
final String description = 'Update a kanban ticket.';
@override
Future<void> run() async {
final rest = argResults!.rest;
if (rest.isEmpty) usageException('Ticket ID is required.');
final id = rest.first.toUpperCase();
final title = argResults!['title'] as String?;
final typeId = argResults!['type'] as String?;
final column = argResults!['column'] as String?;
final body = argResults!['body'] as String?;
if (title == null && typeId == null && column == null && body == null) {
usageException('At least one option must be specified.');
}
final context = await ProjectContext.find();
final config = context.config.kanban;
if (typeId != null && !config.ticketTypes.any((t) => t.id == typeId)) {
usageException(
'Unknown type "$typeId". '
'Valid types: ${config.ticketTypes.map((t) => t.id).join(', ')}',
);
}
if (column != null && !config.columns.any((c) => c.id == column)) {
usageException(
'Unknown column "$column". '
'Valid columns: ${config.columns.map((c) => c.id).join(', ')}',
);
}
final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: config.prefix,
);
try {
final ticket = await store.update(
id,
title: title,
type: typeId,
column: column,
body: body,
);
print('Updated ${ticket.id}.');
} on ArgumentError catch (e) {
usageException(e.message as String);
}
}
}

View file

@ -1,7 +1,19 @@
import 'package:dew_core/dew_core.dart';
import 'commands/create_command.dart';
import 'commands/delete_command.dart';
import 'commands/get_command.dart';
import 'commands/update_command.dart';
/// Top-level CLI command for all Kanban board operations.
class KanbanCommand extends DewCommand {
KanbanCommand() {
addSubcommand(CreateCommand());
addSubcommand(GetCommand());
addSubcommand(UpdateCommand());
addSubcommand(DeleteCommand());
}
@override
final String name = 'kanban';

View file

@ -0,0 +1,102 @@
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;
const Ticket({
required this.id,
required this.title,
required this.type,
required this.column,
required this.created,
required this.body,
required this.comments,
});
Ticket copyWith({
String? title,
String? type,
String? column,
String? body,
List<String>? comments,
}) => 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,
);
/// 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()}');
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 [],
);
}
}

View file

@ -0,0 +1,100 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'ticket.dart';
class TicketStore {
final String kanbanDir;
final String prefix;
const TicketStore({required this.kanbanDir, required this.prefix});
Future<Ticket> create({
required String title,
required String type,
required String column,
String body = '',
}) async {
await Directory(kanbanDir).create(recursive: true);
final id = _formatId(await _nextNumber());
final ticket = Ticket(
id: id,
title: title,
type: type,
column: column,
created: DateTime.now().toUtc(),
body: body,
comments: const [],
);
await File(_filePath(id)).writeAsString(ticket.toFileContent());
return ticket;
}
Future<Ticket?> findById(String id) async {
final file = File(_filePath(id));
if (!await file.exists()) return null;
return Ticket.fromFileContent(id, await file.readAsString());
}
Future<List<Ticket>> list() async {
final dir = Directory(kanbanDir);
if (!await dir.exists()) return const [];
final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-\d{4}\.md$');
final tickets = <Ticket>[];
await for (final entity in dir.list()) {
final name = p.basename(entity.path);
if (pattern.hasMatch(name)) {
final id = p.basenameWithoutExtension(name);
final ticket = await findById(id);
if (ticket != null) tickets.add(ticket);
}
}
tickets.sort((a, b) => a.id.compareTo(b.id));
return tickets;
}
Future<Ticket> update(
String id, {
String? title,
String? type,
String? column,
String? body,
}) async {
final ticket = await findById(id);
if (ticket == null) throw ArgumentError('Ticket $id not found.');
final updated = ticket.copyWith(
title: title,
type: type,
column: column,
body: body,
);
await File(_filePath(id)).writeAsString(updated.toFileContent());
return updated;
}
Future<void> delete(String id) async {
final file = File(_filePath(id));
if (!await file.exists()) throw ArgumentError('Ticket $id not found.');
await file.delete();
}
Future<int> _nextNumber() async {
final dir = Directory(kanbanDir);
if (!await dir.exists()) return 1;
final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$');
var max = 0;
await for (final entity in dir.list()) {
final match = pattern.firstMatch(p.basename(entity.path));
if (match != null) {
final n = int.parse(match.group(1)!);
if (n > max) max = n;
}
}
return max + 1;
}
String _formatId(int n) => '$prefix-${n.toString().padLeft(4, '0')}';
String _filePath(String id) => p.join(kanbanDir, '$id.md');
}

View file

@ -13,6 +13,7 @@ dependencies:
dew_core:
path: ../core
path: ^1.9.0
yaml: ^3.1.0
dev_dependencies:
lints: ^6.0.0

View file

@ -1,5 +1,8 @@
import 'package:dew_kanban/dew_kanban.dart';
import 'dart:io';
import 'package:dew_core/dew_core.dart';
import 'package:dew_kanban/dew_kanban.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
@ -10,10 +13,131 @@ void main() {
expect(cmd.description, isNotEmpty);
});
test('has expected subcommands', () {
final cmd = KanbanCommand();
expect(
cmd.subcommands.keys,
containsAll(['create', 'get', 'update', 'delete']),
);
});
test('registerCommands adds kanban command to registry', () {
final registry = CommandRegistry();
registerCommands(registry);
expect(registry.commands.map((c) => c.name), contains('kanban'));
});
});
group('Ticket', () {
test('roundtrip serialisation', () {
final t = Ticket(
id: 'TEST-0001',
title: 'Do a thing',
type: 'task',
column: 'todo',
created: DateTime.utc(2026, 1, 1, 12),
body: 'Some body.',
comments: ['First comment.', 'Second comment.'],
);
final parsed = Ticket.fromFileContent(t.id, t.toFileContent());
expect(parsed.id, t.id);
expect(parsed.title, t.title);
expect(parsed.type, t.type);
expect(parsed.column, t.column);
expect(parsed.created.toUtc(), t.created.toUtc());
expect(parsed.body, t.body);
expect(parsed.comments, t.comments);
});
test('empty body and no comments', () {
final t = Ticket(
id: 'TEST-0002',
title: 'Empty',
type: 'task',
column: 'todo',
created: DateTime.utc(2026, 1, 2),
body: '',
comments: const [],
);
final parsed = Ticket.fromFileContent(t.id, t.toFileContent());
expect(parsed.body, '');
expect(parsed.comments, isEmpty);
});
});
group('TicketStore', () {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('dew_kanban_test_');
});
tearDown(() => tempDir.delete(recursive: true));
TicketStore makeStore() => TicketStore(
kanbanDir: p.join(tempDir.path, 'kanban'),
prefix: 'TEST',
);
test('create assigns incrementing IDs', () async {
final store = makeStore();
final t1 = await store.create(
title: 'First',
type: 'task',
column: 'todo',
);
final t2 = await store.create(
title: 'Second',
type: 'bug',
column: 'todo',
);
expect(t1.id, 'TEST-0001');
expect(t2.id, 'TEST-0002');
});
test('findById returns null for missing ticket', () async {
final store = makeStore();
expect(await store.findById('TEST-0099'), isNull);
});
test('list returns sorted tickets', () async {
final store = makeStore();
await store.create(title: 'A', type: 'task', column: 'todo');
await store.create(title: 'B', type: 'task', column: 'todo');
final all = await store.list();
expect(all.map((t) => t.id), ['TEST-0001', 'TEST-0002']);
});
test('update patches specified fields', () async {
final store = makeStore();
await store.create(title: 'Old', type: 'task', column: 'todo');
final updated = await store.update('TEST-0001', title: 'New');
expect(updated.title, 'New');
expect(updated.type, 'task');
});
test('update throws for missing ticket', () async {
final store = makeStore();
expect(
() => store.update('TEST-0099', title: 'X'),
throwsA(isA<ArgumentError>()),
);
});
test('delete removes ticket', () async {
final store = makeStore();
await store.create(title: 'Bye', type: 'task', column: 'todo');
await store.delete('TEST-0001');
expect(await store.findById('TEST-0001'), isNull);
});
test('delete throws for missing ticket', () async {
final store = makeStore();
expect(
() => store.delete('TEST-0099'),
throwsA(isA<ArgumentError>()),
);
});
});
}