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:
parent
0723c7d996
commit
a1c36f7136
14 changed files with 734 additions and 3 deletions
|
|
@ -1,3 +1,4 @@
|
|||
library;
|
||||
|
||||
export 'src/config.dart';
|
||||
export 'src/dew_core_base.dart';
|
||||
|
|
|
|||
120
packages/core/lib/src/config.dart
Normal file
120
packages/core/lib/src/config.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ environment:
|
|||
dependencies:
|
||||
args: ^2.7.0
|
||||
path: ^1.9.0
|
||||
yaml: ^3.1.0
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^6.0.0
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
68
packages/kanban/lib/src/commands/create_command.dart
Normal file
68
packages/kanban/lib/src/commands/create_command.dart
Normal 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}.');
|
||||
}
|
||||
}
|
||||
32
packages/kanban/lib/src/commands/delete_command.dart
Normal file
32
packages/kanban/lib/src/commands/delete_command.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
packages/kanban/lib/src/commands/get_command.dart
Normal file
42
packages/kanban/lib/src/commands/get_command.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/kanban/lib/src/commands/update_command.dart
Normal file
71
packages/kanban/lib/src/commands/update_command.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
102
packages/kanban/lib/src/ticket.dart
Normal file
102
packages/kanban/lib/src/ticket.dart
Normal 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 [],
|
||||
);
|
||||
}
|
||||
}
|
||||
100
packages/kanban/lib/src/ticket_store.dart
Normal file
100
packages/kanban/lib/src/ticket_store.dart
Normal 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');
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ dependencies:
|
|||
dew_core:
|
||||
path: ../core
|
||||
path: ^1.9.0
|
||||
yaml: ^3.1.0
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^6.0.0
|
||||
|
|
|
|||
|
|
@ -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>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue