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;
|
library;
|
||||||
|
|
||||||
|
export 'src/config.dart';
|
||||||
export 'src/dew_core_base.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:
|
dependencies:
|
||||||
args: ^2.7.0
|
args: ^2.7.0
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
|
yaml: ^3.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
lints: ^6.0.0
|
lints: ^6.0.0
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
// Minimal concrete command for testing the registry.
|
|
||||||
class _TestCommand extends DewCommand {
|
class _TestCommand extends DewCommand {
|
||||||
@override
|
@override
|
||||||
final String name = 'test-cmd';
|
final String name = 'test-cmd';
|
||||||
|
|
@ -27,7 +29,60 @@ void main() {
|
||||||
|
|
||||||
test('commands list is unmodifiable', () {
|
test('commands list is unmodifiable', () {
|
||||||
final registry = CommandRegistry();
|
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;
|
library;
|
||||||
|
|
||||||
export 'src/dew_kanban_base.dart';
|
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_core/dew_core.dart';
|
||||||
import 'package:dew_kanban/src/dew_kanban_base.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 '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.
|
/// Top-level CLI command for all Kanban board operations.
|
||||||
class KanbanCommand extends DewCommand {
|
class KanbanCommand extends DewCommand {
|
||||||
|
KanbanCommand() {
|
||||||
|
addSubcommand(CreateCommand());
|
||||||
|
addSubcommand(GetCommand());
|
||||||
|
addSubcommand(UpdateCommand());
|
||||||
|
addSubcommand(DeleteCommand());
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String name = 'kanban';
|
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:
|
dew_core:
|
||||||
path: ../core
|
path: ../core
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
|
yaml: ^3.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
lints: ^6.0.0
|
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_core/dew_core.dart';
|
||||||
|
import 'package:dew_kanban/dew_kanban.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -10,10 +13,131 @@ void main() {
|
||||||
expect(cmd.description, isNotEmpty);
|
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', () {
|
test('registerCommands adds kanban command to registry', () {
|
||||||
final registry = CommandRegistry();
|
final registry = CommandRegistry();
|
||||||
registerCommands(registry);
|
registerCommands(registry);
|
||||||
expect(registry.commands.map((c) => c.name), contains('kanban'));
|
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