Refactor filesystem access to package:file

Replace dart:io File/Directory with package:file abstractions so that
tests can use MemoryFileSystem instead of mutating the process-global
Directory.current.

- Add file: ^7.0.1 to core and kanban dependencies
- ProjectContext.find() accepts FileSystem fs parameter
- TicketStore, KanbanInitHook, InitCommand, all kanban commands accept
  FileSystem fs (defaulting to LocalFileSystem())
- KanbanCommand and registerCommands() thread fs to subcommands
- Tests rewritten to use MemoryFileSystem() — no Directory.current mutation
- Remove dart_test.yaml (concurrency: 1 no longer needed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Chris Hendrickson 2026-04-23 22:26:09 -04:00
parent 037f5fde28
commit 8d787235b9
28 changed files with 316 additions and 255 deletions

View file

@ -1,4 +0,0 @@
# Tests that mutate Directory.current (a process-global OS call) must not run
# concurrently across test files in the same process — the last chdir() wins
# and can leave other suites pointing at a deleted temp directory.
concurrency: 1

View file

@ -1,5 +1,5 @@
import 'dart:io'; import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart'; import 'package:yaml/yaml.dart';
@ -10,9 +10,7 @@ import 'package:yaml/yaml.dart';
/// This keeps feature-specific config classes out of core. /// This keeps feature-specific config classes out of core.
class DewConfig { class DewConfig {
final YamlMap raw; final YamlMap raw;
const DewConfig({required this.raw}); const DewConfig({required this.raw});
factory DewConfig.fromYaml(YamlMap yaml) => DewConfig(raw: yaml); factory DewConfig.fromYaml(YamlMap yaml) => DewConfig(raw: yaml);
} }
@ -20,20 +18,25 @@ class DewConfig {
class ProjectContext { class ProjectContext {
final String root; final String root;
final DewConfig config; final DewConfig config;
final FileSystem fs;
const ProjectContext({required this.root, required this.config}); const ProjectContext({required this.root, required this.config, required this.fs});
/// Walks up from [from] (defaults to [Directory.current]) until a /// Walks up from [from] (defaults to [fs.currentDirectory]) until a
/// `.project/dew.yaml` is found. /// `.project/dew.yaml` is found.
static Future<ProjectContext> find({Directory? from}) async { static Future<ProjectContext> find({
var dir = from ?? Directory.current; FileSystem fs = const LocalFileSystem(),
Directory? from,
}) async {
var dir = from ?? fs.currentDirectory;
while (true) { while (true) {
final configFile = File(p.join(dir.path, '.project', 'dew.yaml')); final configFile = fs.file(p.join(dir.path, '.project', 'dew.yaml'));
if (await configFile.exists()) { if (await configFile.exists()) {
final yaml = loadYaml(await configFile.readAsString()) as YamlMap; final yaml = loadYaml(await configFile.readAsString()) as YamlMap;
return ProjectContext( return ProjectContext(
root: dir.path, root: dir.path,
config: DewConfig.fromYaml(yaml), config: DewConfig.fromYaml(yaml),
fs: fs,
); );
} }
final parent = dir.parent; final parent = dir.parent;

View file

@ -1,6 +1,6 @@
import 'dart:io';
import 'package:args/command_runner.dart'; import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart'; import 'package:yaml/yaml.dart';
@ -62,8 +62,9 @@ dew:
class InitCommand extends Command<void> { class InitCommand extends Command<void> {
final List<DewInitHook> _hooks; final List<DewInitHook> _hooks;
final FileSystem _fs;
InitCommand(this._hooks) { InitCommand(this._hooks, {FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption( ..addOption(
'path', 'path',
@ -93,8 +94,8 @@ class InitCommand extends Command<void> {
final projectRoot = p.canonicalize(rawPath); final projectRoot = p.canonicalize(rawPath);
final options = DewInitOptions(gitkeep: gitkeep); final options = DewInitOptions(gitkeep: gitkeep);
final projectDir = Directory(p.join(projectRoot, '.project')); final projectDir = _fs.directory(p.join(projectRoot, '.project'));
final configFile = File(p.join(projectDir.path, 'dew.yaml')); final configFile = _fs.file(p.join(projectDir.path, 'dew.yaml'));
await projectDir.create(recursive: true); await projectDir.create(recursive: true);

View file

@ -11,6 +11,7 @@ environment:
# Add regular dependencies here. # Add regular dependencies here.
dependencies: dependencies:
args: ^2.7.0 args: ^2.7.0
file: ^7.0.1
path: ^1.9.0 path: ^1.9.0
yaml: ^3.1.0 yaml: ^3.1.0

View file

@ -1,7 +1,5 @@
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:file/memory.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
class _TestCommand extends DewCommand { class _TestCommand extends DewCommand {
@ -37,16 +35,7 @@ void main() {
}); });
group('ProjectContext', () { group('ProjectContext', () {
late Directory tempDir; const configYaml = '''
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: dew:
mcp: mcp:
host: localhost host: localhost
@ -60,17 +49,14 @@ dew:
- id: todo - id: todo
name: To Do name: To Do
color: blue color: blue
'''); ''';
Directory.current = tempDir;
});
tearDown(() async {
Directory.current = originalDir;
await tempDir.delete(recursive: true);
});
test('find() loads config and exposes raw yaml', () async { test('find() loads config and exposes raw yaml', () async {
final ctx = await ProjectContext.find(); final fs = MemoryFileSystem();
fs.directory('/.project').createSync(recursive: true);
fs.file('/.project/dew.yaml').writeAsStringSync(configYaml);
final ctx = await ProjectContext.find(fs: fs);
final dew = ctx.config.raw['dew']; final dew = ctx.config.raw['dew'];
expect(dew['kanban']['prefix'], 'TEST'); expect(dew['kanban']['prefix'], 'TEST');
expect(dew['mcp']['host'], 'localhost'); expect(dew['mcp']['host'], 'localhost');
@ -78,10 +64,13 @@ dew:
}); });
test('find() locates config from a subdirectory', () async { test('find() locates config from a subdirectory', () async {
final sub = await Directory(p.join(tempDir.path, 'sub')).create(); final fs = MemoryFileSystem();
Directory.current = sub; fs.directory('/.project').createSync(recursive: true);
final ctx = await ProjectContext.find(); fs.file('/.project/dew.yaml').writeAsStringSync(configYaml);
expect(ctx.root, tempDir.path); fs.directory('/sub').createSync(recursive: true);
final ctx = await ProjectContext.find(fs: fs, from: fs.directory('/sub'));
expect(ctx.root, '/');
}); });
}); });
} }

View file

@ -7,11 +7,16 @@ export 'src/ticket.dart';
export 'src/ticket_store.dart'; export 'src/ticket_store.dart';
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:dew_kanban/src/dew_kanban_base.dart'; import 'package:dew_kanban/src/dew_kanban_base.dart';
import 'package:dew_kanban/src/kanban_init_hook.dart'; import 'package:dew_kanban/src/kanban_init_hook.dart';
/// Registers all Kanban commands and init hooks into [registry]. /// Registers all Kanban commands and init hooks into [registry].
void registerCommands(CommandRegistry registry) { void registerCommands(
registry.register(KanbanCommand()); CommandRegistry registry, {
registry.registerInitHook(KanbanInitHook()); FileSystem fs = const LocalFileSystem(),
}) {
registry.register(KanbanCommand(fs: fs));
registry.registerInitHook(KanbanInitHook(fs: fs));
} }

View file

@ -1,11 +1,15 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
class AddCommentCommand extends DewCommand with DewToolCommand { class AddCommentCommand extends DewCommand with DewToolCommand {
AddCommentCommand() { final FileSystem _fs;
AddCommentCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID (e.g. DEW-0001).') ..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID (e.g. DEW-0001).')
..addOption('comment', abbr: 'm', mandatory: true, help: 'Comment text to append.'); ..addOption('comment', abbr: 'm', mandatory: true, help: 'Comment text to append.');
@ -25,10 +29,11 @@ class AddCommentCommand extends DewCommand with DewToolCommand {
final id = (args['id'] as String).toUpperCase(); final id = (args['id'] as String).toUpperCase();
final comment = args['comment'] as String; final comment = args['comment'] as String;
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs,
); );
await store.addComment(id, comment); await store.addComment(id, comment);
return 'Comment added to $id.'; return 'Comment added to $id.';

View file

@ -1,13 +1,15 @@
import 'dart:io';
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
class ArchiveCommand extends DewCommand with DewToolCommand { class ArchiveCommand extends DewCommand with DewToolCommand {
ArchiveCommand() { final FileSystem _fs;
ArchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser.addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID to archive.'); argParser.addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID to archive.');
} }
@ -24,22 +26,22 @@ class ArchiveCommand extends DewCommand with DewToolCommand {
Future<String> callAsTool(Map<String, dynamic> args) async { Future<String> callAsTool(Map<String, dynamic> args) async {
final id = (args['id'] as String).toUpperCase(); final id = (args['id'] as String).toUpperCase();
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final config = context.config.kanban; final config = context.config.kanban;
final kanbanDir = p.join(context.root, '.project', 'kanban'); final kanbanDir = p.join(context.root, '.project', 'kanban');
final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix); final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix, fs: context.fs);
final ticket = await store.findById(id); final ticket = await store.findById(id);
if (ticket == null) throw ArgumentError('Ticket $id not found.'); if (ticket == null) throw ArgumentError('Ticket $id not found.');
if (ticket.column == 'archive') return '$id is already archived.'; if (ticket.column == 'archive') return '$id is already archived.';
final archiveDir = Directory(p.join(kanbanDir, 'archive')); final archiveDir = context.fs.directory(p.join(kanbanDir, 'archive'));
await archiveDir.create(recursive: true); await archiveDir.create(recursive: true);
// Move the file directly rather than through update() so we don't write // Move the file directly rather than through update() so we don't write
// the column back into the ticket frontmatter (it's derived from the dir). // the column back into the ticket frontmatter (it's derived from the dir).
final srcFile = File(p.join(kanbanDir, ticket.column, '$id.md')); final srcFile = context.fs.file(p.join(kanbanDir, ticket.column, '$id.md'));
final dstFile = File(p.join(archiveDir.path, '$id.md')); final dstFile = context.fs.file(p.join(archiveDir.path, '$id.md'));
await srcFile.rename(dstFile.path); await srcFile.rename(dstFile.path);
return 'Archived $id.'; return 'Archived $id.';

View file

@ -1,4 +1,6 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -6,7 +8,9 @@ import '../ticket.dart';
import '../ticket_store.dart'; import '../ticket_store.dart';
class BoardCommand extends DewCommand with DewToolCommand { class BoardCommand extends DewCommand with DewToolCommand {
BoardCommand() { final FileSystem _fs;
BoardCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('type', abbr: 't', help: 'Filter tickets to this type.') ..addOption('type', abbr: 't', help: 'Filter tickets to this type.')
..addOption('label', help: 'Filter tickets to this label.') ..addOption('label', help: 'Filter tickets to this label.')
@ -28,11 +32,12 @@ class BoardCommand extends DewCommand with DewToolCommand {
final labelFilter = args['label'] as String?; final labelFilter = args['label'] as String?;
final milestoneFilter = args['milestone'] as String?; final milestoneFilter = args['milestone'] as String?;
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final config = context.config.kanban; final config = context.config.kanban;
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: config.prefix, prefix: config.prefix,
fs: context.fs,
); );
var tickets = await store.list(); var tickets = await store.list();

View file

@ -1,11 +1,15 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
class CreateCommand extends DewCommand with DewToolCommand { class CreateCommand extends DewCommand with DewToolCommand {
CreateCommand() { final FileSystem _fs;
CreateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.') ..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.')
..addOption( ..addOption(
@ -37,7 +41,7 @@ class CreateCommand extends DewCommand with DewToolCommand {
@override @override
Future<String> callAsTool(Map<String, dynamic> args) async { Future<String> callAsTool(Map<String, dynamic> args) async {
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final config = context.config.kanban; final config = context.config.kanban;
final title = args['title'] as String; final title = args['title'] as String;
@ -65,6 +69,7 @@ class CreateCommand extends DewCommand with DewToolCommand {
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: config.prefix, prefix: config.prefix,
fs: context.fs,
); );
final ticket = await store.create( final ticket = await store.create(
title: title, title: title,

View file

@ -1,11 +1,15 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
class DeleteCommand extends DewCommand with DewToolCommand { class DeleteCommand extends DewCommand with DewToolCommand {
DeleteCommand() { final FileSystem _fs;
DeleteCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser.addOption( argParser.addOption(
'id', 'id',
abbr: 'i', abbr: 'i',
@ -26,10 +30,11 @@ class DeleteCommand extends DewCommand with DewToolCommand {
@override @override
Future<String> callAsTool(Map<String, dynamic> args) async { Future<String> callAsTool(Map<String, dynamic> args) async {
final id = (args['id'] as String).toUpperCase(); final id = (args['id'] as String).toUpperCase();
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs,
); );
await store.delete(id); await store.delete(id);
return 'Deleted $id.'; return 'Deleted $id.';

View file

@ -1,4 +1,6 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -6,7 +8,9 @@ import '../ticket.dart';
import '../ticket_store.dart'; import '../ticket_store.dart';
class GetCommand extends DewCommand with DewToolCommand { class GetCommand extends DewCommand with DewToolCommand {
GetCommand() { final FileSystem _fs;
GetCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser.addOption( argParser.addOption(
'id', 'id',
abbr: 'i', abbr: 'i',
@ -27,10 +31,11 @@ class GetCommand extends DewCommand with DewToolCommand {
@override @override
Future<String> callAsTool(Map<String, dynamic> args) async { Future<String> callAsTool(Map<String, dynamic> args) async {
final id = (args['id'] as String).toUpperCase(); final id = (args['id'] as String).toUpperCase();
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs,
); );
final ticket = await store.findById(id); final ticket = await store.findById(id);
if (ticket == null) throw ArgumentError('Ticket $id not found.'); if (ticket == null) throw ArgumentError('Ticket $id not found.');

View file

@ -1,7 +1,12 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
class GetConfigCommand extends DewCommand with DewToolCommand { class GetConfigCommand extends DewCommand with DewToolCommand {
final FileSystem _fs;
GetConfigCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
@override @override
final String name = 'config'; final String name = 'config';
@ -13,7 +18,7 @@ class GetConfigCommand extends DewCommand with DewToolCommand {
@override @override
Future<String> callAsTool(Map<String, dynamic> args) async { Future<String> callAsTool(Map<String, dynamic> args) async {
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final config = context.config.kanban; final config = context.config.kanban;
final columns = config.columns.map((c) => '${c.id} (${c.name})').join(', '); final columns = config.columns.map((c) => '${c.id} (${c.name})').join(', ');

View file

@ -1,4 +1,6 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -6,7 +8,9 @@ import '../ticket.dart';
import '../ticket_store.dart'; import '../ticket_store.dart';
class LinkCommand extends DewCommand with DewToolCommand { class LinkCommand extends DewCommand with DewToolCommand {
LinkCommand() { final FileSystem _fs;
LinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.') ..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.')
..addOption('target', abbr: 't', mandatory: true, help: 'Target ticket ID.') ..addOption('target', abbr: 't', mandatory: true, help: 'Target ticket ID.')
@ -38,10 +42,11 @@ class LinkCommand extends DewCommand with DewToolCommand {
if (id == targetId) throw ArgumentError('A ticket cannot be linked to itself.'); if (id == targetId) throw ArgumentError('A ticket cannot be linked to itself.');
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs,
); );
await store.linkTickets(id, targetId, type); await store.linkTickets(id, targetId, type);

View file

@ -1,4 +1,6 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -6,7 +8,9 @@ import '../ticket.dart';
import '../ticket_store.dart'; import '../ticket_store.dart';
class ListCommand extends DewCommand with DewToolCommand { class ListCommand extends DewCommand with DewToolCommand {
ListCommand() { final FileSystem _fs;
ListCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
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.')
@ -32,10 +36,11 @@ class ListCommand extends DewCommand with DewToolCommand {
final milestoneFilter = args['milestone'] as String?; final milestoneFilter = args['milestone'] as String?;
final includeArchived = args['include-archived'] as bool? ?? false; final includeArchived = args['include-archived'] as bool? ?? false;
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs,
); );
var tickets = await store.list(includeArchived: includeArchived); var tickets = await store.list(includeArchived: includeArchived);

View file

@ -1,11 +1,15 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
class MoveCommand extends DewCommand with DewToolCommand { class MoveCommand extends DewCommand with DewToolCommand {
MoveCommand() { final FileSystem _fs;
MoveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.') ..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.')
..addOption('column', abbr: 'c', mandatory: true, help: 'Target column ID.'); ..addOption('column', abbr: 'c', mandatory: true, help: 'Target column ID.');
@ -25,7 +29,7 @@ class MoveCommand extends DewCommand with DewToolCommand {
final id = (args['id'] as String).toUpperCase(); final id = (args['id'] as String).toUpperCase();
final column = args['column'] as String; final column = args['column'] as String;
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final config = context.config.kanban; final config = context.config.kanban;
if (!config.columns.any((c) => c.id == column)) { if (!config.columns.any((c) => c.id == column)) {
@ -38,6 +42,7 @@ class MoveCommand extends DewCommand with DewToolCommand {
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: config.prefix, prefix: config.prefix,
fs: context.fs,
); );
final ticket = await store.findById(id); final ticket = await store.findById(id);

View file

@ -1,11 +1,15 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
class SearchCommand extends DewCommand with DewToolCommand { class SearchCommand extends DewCommand with DewToolCommand {
SearchCommand() { final FileSystem _fs;
SearchCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption( ..addOption(
'query', 'query',
@ -38,10 +42,11 @@ class SearchCommand extends DewCommand with DewToolCommand {
final milestoneFilter = args['milestone'] as String?; final milestoneFilter = args['milestone'] as String?;
final includeArchived = args['include-archived'] as bool? ?? false; final includeArchived = args['include-archived'] as bool? ?? false;
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs,
); );
var tickets = await store.list(includeArchived: includeArchived); var tickets = await store.list(includeArchived: includeArchived);

View file

@ -1,10 +1,15 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
class StatsCommand extends DewCommand with DewToolCommand { class StatsCommand extends DewCommand with DewToolCommand {
final FileSystem _fs;
StatsCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
@override @override
final String name = 'stats'; final String name = 'stats';
@ -16,11 +21,12 @@ class StatsCommand extends DewCommand with DewToolCommand {
@override @override
Future<String> callAsTool(Map<String, dynamic> args) async { Future<String> callAsTool(Map<String, dynamic> args) async {
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final config = context.config.kanban; final config = context.config.kanban;
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: config.prefix, prefix: config.prefix,
fs: context.fs,
); );
final stats = await store.stats(); final stats = await store.stats();

View file

@ -1,13 +1,15 @@
import 'dart:io';
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
class UnarchiveCommand extends DewCommand with DewToolCommand { class UnarchiveCommand extends DewCommand with DewToolCommand {
UnarchiveCommand() { final FileSystem _fs;
UnarchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID to unarchive.') ..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID to unarchive.')
..addOption( ..addOption(
@ -30,11 +32,11 @@ class UnarchiveCommand extends DewCommand with DewToolCommand {
Future<String> callAsTool(Map<String, dynamic> args) async { Future<String> callAsTool(Map<String, dynamic> args) async {
final id = (args['id'] as String).toUpperCase(); final id = (args['id'] as String).toUpperCase();
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final config = context.config.kanban; final config = context.config.kanban;
final kanbanDir = p.join(context.root, '.project', 'kanban'); final kanbanDir = p.join(context.root, '.project', 'kanban');
final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix); final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix, fs: context.fs);
final ticket = await store.findById(id); final ticket = await store.findById(id);
if (ticket == null) throw ArgumentError('Ticket $id not found.'); if (ticket == null) throw ArgumentError('Ticket $id not found.');
if (ticket.column != 'archive') return '$id is not archived.'; if (ticket.column != 'archive') return '$id is not archived.';
@ -48,11 +50,11 @@ class UnarchiveCommand extends DewCommand with DewToolCommand {
); );
} }
final targetDir = Directory(p.join(kanbanDir, targetColumn)); final targetDir = context.fs.directory(p.join(kanbanDir, targetColumn));
await targetDir.create(recursive: true); await targetDir.create(recursive: true);
final srcFile = File(p.join(kanbanDir, 'archive', '$id.md')); final srcFile = context.fs.file(p.join(kanbanDir, 'archive', '$id.md'));
final dstFile = File(p.join(targetDir.path, '$id.md')); final dstFile = context.fs.file(p.join(targetDir.path, '$id.md'));
await srcFile.rename(dstFile.path); await srcFile.rename(dstFile.path);
return 'Restored $id to "$targetColumn".'; return 'Restored $id to "$targetColumn".';

View file

@ -1,11 +1,15 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
class UnlinkCommand extends DewCommand with DewToolCommand { class UnlinkCommand extends DewCommand with DewToolCommand {
UnlinkCommand() { final FileSystem _fs;
UnlinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.') ..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.')
..addOption('target', abbr: 't', mandatory: true, help: 'Target ticket ID to remove link to.'); ..addOption('target', abbr: 't', mandatory: true, help: 'Target ticket ID to remove link to.');
@ -25,10 +29,11 @@ class UnlinkCommand extends DewCommand with DewToolCommand {
final id = (args['id'] as String).toUpperCase(); final id = (args['id'] as String).toUpperCase();
final targetId = (args['target'] as String).toUpperCase(); final targetId = (args['target'] as String).toUpperCase();
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs,
); );
await store.unlinkTickets(id, targetId); await store.unlinkTickets(id, targetId);

View file

@ -1,11 +1,15 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
class UpdateCommand extends DewCommand with DewToolCommand { class UpdateCommand extends DewCommand with DewToolCommand {
UpdateCommand() { final FileSystem _fs;
UpdateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.') ..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.')
..addOption('title', abbr: 't', help: 'New title.') ..addOption('title', abbr: 't', help: 'New title.')
@ -58,7 +62,7 @@ class UpdateCommand extends DewCommand with DewToolCommand {
); );
} }
final context = await ProjectContext.find(); final context = await ProjectContext.find(fs: _fs);
final config = context.config.kanban; final config = context.config.kanban;
if (typeId != null && !config.ticketTypes.any((t) => t.id == typeId)) { if (typeId != null && !config.ticketTypes.any((t) => t.id == typeId)) {
@ -77,6 +81,7 @@ class UpdateCommand extends DewCommand with DewToolCommand {
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: p.join(context.root, '.project', 'kanban'),
prefix: config.prefix, prefix: config.prefix,
fs: context.fs,
); );
final ticket = await store.update( final ticket = await store.update(
id, id,

View file

@ -1,4 +1,6 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'commands/add_comment_command.dart'; import 'commands/add_comment_command.dart';
import 'commands/archive_command.dart'; import 'commands/archive_command.dart';
@ -18,22 +20,22 @@ 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() { KanbanCommand({FileSystem fs = const LocalFileSystem()}) {
addSubcommand(CreateCommand()); addSubcommand(CreateCommand(fs: fs));
addSubcommand(ListCommand()); addSubcommand(ListCommand(fs: fs));
addSubcommand(BoardCommand()); addSubcommand(BoardCommand(fs: fs));
addSubcommand(GetCommand()); addSubcommand(GetCommand(fs: fs));
addSubcommand(UpdateCommand()); addSubcommand(UpdateCommand(fs: fs));
addSubcommand(DeleteCommand()); addSubcommand(DeleteCommand(fs: fs));
addSubcommand(ArchiveCommand()); addSubcommand(ArchiveCommand(fs: fs));
addSubcommand(UnarchiveCommand()); addSubcommand(UnarchiveCommand(fs: fs));
addSubcommand(MoveCommand()); addSubcommand(MoveCommand(fs: fs));
addSubcommand(SearchCommand()); addSubcommand(SearchCommand(fs: fs));
addSubcommand(AddCommentCommand()); addSubcommand(AddCommentCommand(fs: fs));
addSubcommand(GetConfigCommand()); addSubcommand(GetConfigCommand(fs: fs));
addSubcommand(StatsCommand()); addSubcommand(StatsCommand(fs: fs));
addSubcommand(LinkCommand()); addSubcommand(LinkCommand(fs: fs));
addSubcommand(UnlinkCommand()); addSubcommand(UnlinkCommand(fs: fs));
} }
@override @override

View file

@ -1,11 +1,15 @@
import 'dart:io';
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'kanban_config.dart'; import 'kanban_config.dart';
class KanbanInitHook implements DewInitHook { class KanbanInitHook implements DewInitHook {
final FileSystem _fs;
KanbanInitHook({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
@override @override
Future<void> onInit( Future<void> onInit(
String projectRoot, String projectRoot,
@ -26,7 +30,7 @@ class KanbanInitHook implements DewInitHook {
} }
Future<void> _createDir(String path, bool gitkeep) async { Future<void> _createDir(String path, bool gitkeep) async {
final dir = Directory(path); final dir = _fs.directory(path);
final existed = await dir.exists(); final existed = await dir.exists();
await dir.create(recursive: true); await dir.create(recursive: true);
final rel = '.project/kanban/${p.basename(path)}'; final rel = '.project/kanban/${p.basename(path)}';
@ -35,7 +39,7 @@ class KanbanInitHook implements DewInitHook {
} else { } else {
print(' created $rel/'); print(' created $rel/');
if (gitkeep) { if (gitkeep) {
await File(p.join(path, '.gitkeep')).writeAsString(''); await _fs.file(p.join(path, '.gitkeep')).writeAsString('');
print(' created $rel/.gitkeep'); print(' created $rel/.gitkeep');
} }
} }

View file

@ -1,5 +1,5 @@
import 'dart:io'; import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'ticket.dart'; import 'ticket.dart';
@ -7,8 +7,13 @@ import 'ticket.dart';
class TicketStore { class TicketStore {
final String kanbanDir; final String kanbanDir;
final String prefix; final String prefix;
final FileSystem fs;
const TicketStore({required this.kanbanDir, required this.prefix}); const TicketStore({
required this.kanbanDir,
required this.prefix,
this.fs = const LocalFileSystem(),
});
Future<Ticket> create({ Future<Ticket> create({
required String title, required String title,
@ -18,7 +23,7 @@ class TicketStore {
List<String> milestones = const [], List<String> milestones = const [],
List<String> labels = const [], List<String> labels = const [],
}) async { }) async {
final columnDir = Directory(p.join(kanbanDir, column)); final columnDir = fs.directory(p.join(kanbanDir, column));
await columnDir.create(recursive: true); await columnDir.create(recursive: true);
final id = _formatId(await _nextNumber()); final id = _formatId(await _nextNumber());
final ticket = Ticket( final ticket = Ticket(
@ -32,7 +37,7 @@ class TicketStore {
milestones: milestones, milestones: milestones,
labels: labels, labels: labels,
); );
await File(p.join(columnDir.path, '$id.md')).writeAsString(ticket.toFileContent()); await fs.file(p.join(columnDir.path, '$id.md')).writeAsString(ticket.toFileContent());
return ticket; return ticket;
} }
@ -43,7 +48,7 @@ class TicketStore {
} }
Future<List<Ticket>> list({bool includeArchived = false}) async { Future<List<Ticket>> list({bool includeArchived = false}) async {
final dir = Directory(kanbanDir); final dir = fs.directory(kanbanDir);
if (!await dir.exists()) return const []; if (!await dir.exists()) return const [];
final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-\d{4}\.md$'); final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-\d{4}\.md$');
final tickets = <Ticket>[]; final tickets = <Ticket>[];
@ -168,9 +173,9 @@ class TicketStore {
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();
final newColDir = Directory(p.join(kanbanDir, column)); final newColDir = fs.directory(p.join(kanbanDir, column));
await newColDir.create(recursive: true); await newColDir.create(recursive: true);
await File(p.join(newColDir.path, '$id.md')).writeAsString(updated.toFileContent()); await fs.file(p.join(newColDir.path, '$id.md')).writeAsString(updated.toFileContent());
} else { } else {
await found.file.writeAsString(updated.toFileContent()); await found.file.writeAsString(updated.toFileContent());
} }
@ -182,26 +187,26 @@ class TicketStore {
if (found == null) throw ArgumentError('Ticket $id not found.'); if (found == null) throw ArgumentError('Ticket $id not found.');
await found.file.delete(); await found.file.delete();
// Clean up per-ticket attachment directory if present. // Clean up per-ticket attachment directory if present.
final attachmentsDir = Directory(p.join(kanbanDir, 'attachments', id)); final attachmentsDir = fs.directory(p.join(kanbanDir, 'attachments', id));
if (await attachmentsDir.exists()) await attachmentsDir.delete(recursive: true); if (await attachmentsDir.exists()) await attachmentsDir.delete(recursive: true);
} }
/// Searches all column subdirectories (one level deep) for a ticket file. /// Searches all column subdirectories (one level deep) for a ticket file.
/// Skips the [attachments] directory. Includes [archive]. /// Skips the [attachments] directory. Includes [archive].
Future<({File file, String column})?> _findTicketFile(String id) async { Future<({File file, String column})?> _findTicketFile(String id) async {
final dir = Directory(kanbanDir); final dir = fs.directory(kanbanDir);
if (!await dir.exists()) return null; if (!await dir.exists()) return null;
await for (final entity in dir.list()) { await for (final entity in dir.list()) {
if (entity is! Directory) continue; if (entity is! Directory) continue;
if (p.basename(entity.path) == 'attachments') continue; if (p.basename(entity.path) == 'attachments') continue;
final file = File(p.join(entity.path, '$id.md')); final file = fs.file(p.join(entity.path, '$id.md'));
if (await file.exists()) return (file: file, column: p.basename(entity.path)); if (await file.exists()) return (file: file, column: p.basename(entity.path));
} }
return null; return null;
} }
Future<int> _nextNumber() async { Future<int> _nextNumber() async {
final dir = Directory(kanbanDir); final dir = fs.directory(kanbanDir);
if (!await dir.exists()) return 1; if (!await dir.exists()) return 1;
final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$'); final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$');
var max = 0; var max = 0;

View file

@ -12,6 +12,7 @@ environment:
dependencies: dependencies:
dew_core: dew_core:
path: ../core path: ../core
file: ^7.0.1
path: ^1.9.0 path: ^1.9.0
yaml: ^3.1.0 yaml: ^3.1.0

View file

@ -1,10 +1,31 @@
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:dew_kanban/dew_kanban.dart';
import 'package:path/path.dart' as p; import 'package:file/memory.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
const _testConfig = '''
dew:
mcp:
host: localhost
port: 9090
kanban:
prefix: T
ticket_types:
- id: task
name: Task
columns:
- id: todo
name: To Do
color: blue
''';
MemoryFileSystem _makeFs() {
final fs = MemoryFileSystem();
fs.directory('/.project/kanban').createSync(recursive: true);
fs.file('/.project/dew.yaml').writeAsStringSync(_testConfig);
return fs;
}
void main() { void main() {
group('KanbanCommand', () { group('KanbanCommand', () {
test('has correct name and description', () { test('has correct name and description', () {
@ -75,69 +96,44 @@ void main() {
}); });
test('create and list tools have working handlers', () async { test('create and list tools have working handlers', () async {
final tempDir = await Directory.systemTemp.createTemp('kanban_tool_test_'); final fs = _makeFs();
final origDir = Directory.current; final registry = CommandRegistry();
try { registerCommands(registry, fs: fs);
await Directory(p.join(tempDir.path, '.project', 'kanban')).create(recursive: true); final tools = {for (final t in registry.mcpTools) t.name: t};
await File(p.join(tempDir.path, '.project', 'dew.yaml')).writeAsString('''
dew:
mcp:
host: localhost
port: 9090
kanban:
prefix: T
ticket_types:
- id: task
name: Task
columns:
- id: todo
name: To Do
color: blue
''');
Directory.current = tempDir;
final registry = CommandRegistry();
registerCommands(registry);
final tools = {for (final t in registry.mcpTools) t.name: t};
final result = await tools['kanban_create_ticket']!.handler({ final result = await tools['kanban_create_ticket']!.handler({
'title': 'Hello', 'title': 'Hello',
'type': 'task', 'type': 'task',
}); });
expect(result, contains('T-0001')); expect(result, contains('T-0001'));
final listResult = await tools['kanban_list_tickets']!.handler({}); final listResult = await tools['kanban_list_tickets']!.handler({});
expect(listResult, contains('T-0001')); expect(listResult, contains('T-0001'));
final searchResult = await tools['kanban_search_tickets']!.handler({'query': 'Hello'}); final searchResult = await tools['kanban_search_tickets']!.handler({'query': 'Hello'});
expect(searchResult, contains('T-0001')); expect(searchResult, contains('T-0001'));
await tools['kanban_add_comment']!.handler({'id': 'T-0001', 'comment': 'Nice ticket.'}); await tools['kanban_add_comment']!.handler({'id': 'T-0001', 'comment': 'Nice ticket.'});
final getResult = await tools['kanban_get_ticket']!.handler({'id': 'T-0001'}); final getResult = await tools['kanban_get_ticket']!.handler({'id': 'T-0001'});
expect(getResult, contains('Nice ticket.')); expect(getResult, contains('Nice ticket.'));
final configResult = await tools['kanban_get_config']!.handler({}); final configResult = await tools['kanban_get_config']!.handler({});
expect(configResult, contains('todo')); expect(configResult, contains('todo'));
expect(configResult, contains('task')); expect(configResult, contains('task'));
final statsResult = await tools['kanban_stats']!.handler({}); final statsResult = await tools['kanban_stats']!.handler({});
expect(statsResult, contains('Total: 1')); expect(statsResult, contains('Total: 1'));
expect(statsResult, contains('todo: 1')); expect(statsResult, contains('todo: 1'));
expect(statsResult, contains('task: 1')); expect(statsResult, contains('task: 1'));
} finally {
Directory.current = origDir;
await tempDir.delete(recursive: true);
}
}); });
}); });
group('MoveCommand transition validation', () { group('MoveCommand transition validation', () {
test('move respects allowed_transitions when configured', () async { test('move respects allowed_transitions when configured', () async {
final tempDir = await Directory.systemTemp.createTemp('kanban_transitions_test_'); final fs = MemoryFileSystem();
final origDir = Directory.current; fs.directory('/.project/kanban').createSync(recursive: true);
try { fs.file('/.project/dew.yaml').writeAsStringSync('''
await Directory(p.join(tempDir.path, '.project', 'kanban')).create(recursive: true);
await File(p.join(tempDir.path, '.project', 'dew.yaml')).writeAsString('''
dew: dew:
mcp: mcp:
host: localhost host: localhost
@ -163,48 +159,39 @@ dew:
name: Done name: Done
color: green color: green
'''); ''');
Directory.current = tempDir; final registry = CommandRegistry();
final registry = CommandRegistry(); registerCommands(registry, fs: fs);
registerCommands(registry); final tools = {for (final t in registry.mcpTools) t.name: t};
final tools = {for (final t in registry.mcpTools) t.name: t};
await tools['kanban_create_ticket']!.handler({ await tools['kanban_create_ticket']!.handler({
'title': 'Flow test', 'title': 'Flow test',
'type': 'task', 'type': 'task',
}); });
// Allowed: backlog doing // Allowed: backlog doing
await expectLater( await expectLater(
tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'}), tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'}),
completes, completes,
); );
// Disallowed: doing done is allowed, but doing backlog is also // Reset to backlog first.
// allowed; skip to testing a rejected transition: await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'backlog'});
// "done" has no allowed_transitions (unconstrained), but let's test
// that "backlog" column can't go directly to "done".
// Reset to backlog first.
await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'backlog'});
// backlog done should throw (not in allowed_transitions). // backlog done should throw (not in allowed_transitions).
await expectLater( await expectLater(
tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'}), tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'}),
throwsA(isA<ArgumentError>()), throwsA(isA<ArgumentError>()),
); );
// Unconstrained column (done) any target is valid. // Unconstrained column (done) any target is valid.
await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'}); await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'});
await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'}); await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'});
// done backlog: done has no constraints, so it's allowed. // done backlog: done has no constraints, so it's allowed.
final result = await tools['kanban_move_ticket']!.handler({ final result = await tools['kanban_move_ticket']!.handler({
'id': 'T-0001', 'id': 'T-0001',
'column': 'backlog', 'column': 'backlog',
}); });
expect(result, contains('T-0001')); expect(result, contains('T-0001'));
} finally {
Directory.current = origDir;
await tempDir.delete(recursive: true);
}
}); });
}); });
@ -313,42 +300,30 @@ dew:
}); });
group('TicketStore', () { group('TicketStore', () {
late Directory tempDir; TicketStore makeStore(MemoryFileSystem fs) => TicketStore(
kanbanDir: '/kanban',
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', prefix: 'TEST',
fs: fs,
); );
test('create assigns incrementing IDs', () async { test('create assigns incrementing IDs', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final t1 = await store.create( final store = makeStore(fs);
title: 'First', final t1 = await store.create(title: 'First', type: 'task', column: 'todo');
type: 'task', final t2 = await store.create(title: 'Second', type: 'bug', column: 'todo');
column: 'todo',
);
final t2 = await store.create(
title: 'Second',
type: 'bug',
column: 'todo',
);
expect(t1.id, 'TEST-0001'); expect(t1.id, 'TEST-0001');
expect(t2.id, 'TEST-0002'); expect(t2.id, 'TEST-0002');
}); });
test('findById returns null for missing ticket', () async { test('findById returns null for missing ticket', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
expect(await store.findById('TEST-0099'), isNull); expect(await store.findById('TEST-0099'), isNull);
}); });
test('create and list milestones/labels persist via store', () async { test('create and list milestones/labels persist via store', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
await store.create( await store.create(
title: 'Tagged', title: 'Tagged',
type: 'task', type: 'task',
@ -365,7 +340,8 @@ dew:
}); });
test('update patches milestones and labels', () async { test('update patches milestones and labels', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
await store.create( await store.create(
title: 'Tagged', title: 'Tagged',
type: 'task', type: 'task',
@ -379,7 +355,8 @@ dew:
}); });
test('list returns sorted tickets', () async { test('list returns sorted tickets', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'A', type: 'task', column: 'todo');
await store.create(title: 'B', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo');
final all = await store.list(); final all = await store.list();
@ -387,14 +364,13 @@ dew:
}); });
test('list excludes archive by default, includes with flag', () async { test('list excludes archive by default, includes with flag', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
await store.create(title: 'Active', type: 'task', column: 'todo'); await store.create(title: 'Active', type: 'task', column: 'todo');
// Manually move to archive dir to simulate archived state. // Manually move to archive dir to simulate archived state.
final kanbanDir = Directory(p.join(tempDir.path, 'kanban')); fs.directory('/kanban/archive').createSync(recursive: true);
final archiveDir = Directory(p.join(kanbanDir.path, 'archive')); final src = fs.file('/kanban/todo/TEST-0001.md');
await archiveDir.create(recursive: true); await src.rename('/kanban/archive/TEST-0001.md');
final src = File(p.join(kanbanDir.path, 'todo', 'TEST-0001.md'));
await src.rename(p.join(archiveDir.path, 'TEST-0001.md'));
expect(await store.list(), isEmpty); expect(await store.list(), isEmpty);
final withArchive = await store.list(includeArchived: true); final withArchive = await store.list(includeArchived: true);
@ -403,7 +379,8 @@ dew:
}); });
test('update patches specified fields', () async { test('update patches specified fields', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
await store.create(title: 'Old', type: 'task', column: 'todo'); await store.create(title: 'Old', type: 'task', column: 'todo');
final updated = await store.update('TEST-0001', title: 'New'); final updated = await store.update('TEST-0001', title: 'New');
expect(updated.title, 'New'); expect(updated.title, 'New');
@ -411,7 +388,8 @@ dew:
}); });
test('update throws for missing ticket', () async { test('update throws for missing ticket', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
expect( expect(
() => store.update('TEST-0099', title: 'X'), () => store.update('TEST-0099', title: 'X'),
throwsA(isA<ArgumentError>()), throwsA(isA<ArgumentError>()),
@ -419,14 +397,16 @@ dew:
}); });
test('delete removes ticket', () async { test('delete removes ticket', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
await store.create(title: 'Bye', type: 'task', column: 'todo'); await store.create(title: 'Bye', type: 'task', column: 'todo');
await store.delete('TEST-0001'); await store.delete('TEST-0001');
expect(await store.findById('TEST-0001'), isNull); expect(await store.findById('TEST-0001'), isNull);
}); });
test('delete throws for missing ticket', () async { test('delete throws for missing ticket', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
expect( expect(
() => store.delete('TEST-0099'), () => store.delete('TEST-0099'),
throwsA(isA<ArgumentError>()), throwsA(isA<ArgumentError>()),
@ -434,7 +414,8 @@ dew:
}); });
test('linkTickets adds typed link bidirectionally and is idempotent', () async { test('linkTickets adds typed link bidirectionally and is idempotent', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'A', type: 'task', column: 'todo');
await store.create(title: 'B', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo');
await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks'); await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks');
@ -457,7 +438,8 @@ dew:
}); });
test('linkTickets relates_to is symmetric', () async { test('linkTickets relates_to is symmetric', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'A', type: 'task', column: 'todo');
await store.create(title: 'B', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo');
await store.linkTickets('TEST-0001', 'TEST-0002', 'relates_to'); await store.linkTickets('TEST-0001', 'TEST-0002', 'relates_to');
@ -469,7 +451,8 @@ dew:
}); });
test('linkTickets parent_of / child_of inverse pair', () async { test('linkTickets parent_of / child_of inverse pair', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
await store.create(title: 'Epic', type: 'task', column: 'todo'); await store.create(title: 'Epic', type: 'task', column: 'todo');
await store.create(title: 'Story', type: 'task', column: 'todo'); await store.create(title: 'Story', type: 'task', column: 'todo');
await store.linkTickets('TEST-0001', 'TEST-0002', 'parent_of'); await store.linkTickets('TEST-0001', 'TEST-0002', 'parent_of');
@ -481,13 +464,15 @@ dew:
}); });
test('linkTickets throws for self-link via command', () async { test('linkTickets throws for self-link via command', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'A', type: 'task', column: 'todo');
// Self-link guard is in the command layer, not the store. // Self-link guard is in the command layer, not the store.
}); });
test('unlinkTickets removes link on both sides', () async { test('unlinkTickets removes link on both sides', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'A', type: 'task', column: 'todo');
await store.create(title: 'B', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo');
await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks'); await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks');
@ -500,7 +485,8 @@ dew:
}); });
test('stats returns correct counts', () async { test('stats returns correct counts', () async {
final store = makeStore(); final fs = MemoryFileSystem();
final store = makeStore(fs);
await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'A', type: 'task', column: 'todo');
await store.create(title: 'B', type: 'task', column: 'done'); await store.create(title: 'B', type: 'task', column: 'done');
await store.create(title: 'C', type: 'bug', column: 'todo'); await store.create(title: 'C', type: 'bug', column: 'todo');
@ -512,7 +498,4 @@ dew:
expect((s['byType'] as Map)['bug'], 1); expect((s['byType'] as Map)['bug'], 1);
}); });
}); });
} }

View file

@ -138,7 +138,7 @@ packages:
source: hosted source: hosted
version: "0.5.0" version: "0.5.0"
file: file:
dependency: transitive dependency: "direct dev"
description: description:
name: file name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4

View file

@ -14,6 +14,7 @@ workspace:
- packages/mcp - packages/mcp
dev_dependencies: dev_dependencies:
file: ^7.0.1
lints: ^6.0.0 lints: ^6.0.0
melos: ^7.0.0 melos: ^7.0.0