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:
parent
037f5fde28
commit
8d787235b9
28 changed files with 316 additions and 255 deletions
|
|
@ -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
|
||||
|
|
@ -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:yaml/yaml.dart';
|
||||
|
||||
|
|
@ -10,9 +10,7 @@ import 'package:yaml/yaml.dart';
|
|||
/// This keeps feature-specific config classes out of core.
|
||||
class DewConfig {
|
||||
final YamlMap raw;
|
||||
|
||||
const DewConfig({required this.raw});
|
||||
|
||||
factory DewConfig.fromYaml(YamlMap yaml) => DewConfig(raw: yaml);
|
||||
}
|
||||
|
||||
|
|
@ -20,20 +18,25 @@ class DewConfig {
|
|||
class ProjectContext {
|
||||
final String root;
|
||||
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.
|
||||
static Future<ProjectContext> find({Directory? from}) async {
|
||||
var dir = from ?? Directory.current;
|
||||
static Future<ProjectContext> find({
|
||||
FileSystem fs = const LocalFileSystem(),
|
||||
Directory? from,
|
||||
}) async {
|
||||
var dir = from ?? fs.currentDirectory;
|
||||
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()) {
|
||||
final yaml = loadYaml(await configFile.readAsString()) as YamlMap;
|
||||
return ProjectContext(
|
||||
root: dir.path,
|
||||
config: DewConfig.fromYaml(yaml),
|
||||
fs: fs,
|
||||
);
|
||||
}
|
||||
final parent = dir.parent;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import 'dart:io';
|
||||
|
||||
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:yaml/yaml.dart';
|
||||
|
||||
|
|
@ -62,8 +62,9 @@ dew:
|
|||
|
||||
class InitCommand extends Command<void> {
|
||||
final List<DewInitHook> _hooks;
|
||||
final FileSystem _fs;
|
||||
|
||||
InitCommand(this._hooks) {
|
||||
InitCommand(this._hooks, {FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||
argParser
|
||||
..addOption(
|
||||
'path',
|
||||
|
|
@ -93,8 +94,8 @@ class InitCommand extends Command<void> {
|
|||
final projectRoot = p.canonicalize(rawPath);
|
||||
final options = DewInitOptions(gitkeep: gitkeep);
|
||||
|
||||
final projectDir = Directory(p.join(projectRoot, '.project'));
|
||||
final configFile = File(p.join(projectDir.path, 'dew.yaml'));
|
||||
final projectDir = _fs.directory(p.join(projectRoot, '.project'));
|
||||
final configFile = _fs.file(p.join(projectDir.path, 'dew.yaml'));
|
||||
|
||||
await projectDir.create(recursive: true);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ environment:
|
|||
# Add regular dependencies here.
|
||||
dependencies:
|
||||
args: ^2.7.0
|
||||
file: ^7.0.1
|
||||
path: ^1.9.0
|
||||
yaml: ^3.1.0
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:file/memory.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class _TestCommand extends DewCommand {
|
||||
|
|
@ -37,16 +35,7 @@ void main() {
|
|||
});
|
||||
|
||||
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('''
|
||||
const configYaml = '''
|
||||
dew:
|
||||
mcp:
|
||||
host: localhost
|
||||
|
|
@ -60,17 +49,14 @@ dew:
|
|||
- id: todo
|
||||
name: To Do
|
||||
color: blue
|
||||
''');
|
||||
Directory.current = tempDir;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
Directory.current = originalDir;
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
''';
|
||||
|
||||
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'];
|
||||
expect(dew['kanban']['prefix'], 'TEST');
|
||||
expect(dew['mcp']['host'], 'localhost');
|
||||
|
|
@ -78,10 +64,13 @@ dew:
|
|||
});
|
||||
|
||||
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);
|
||||
final fs = MemoryFileSystem();
|
||||
fs.directory('/.project').createSync(recursive: true);
|
||||
fs.file('/.project/dew.yaml').writeAsStringSync(configYaml);
|
||||
fs.directory('/sub').createSync(recursive: true);
|
||||
|
||||
final ctx = await ProjectContext.find(fs: fs, from: fs.directory('/sub'));
|
||||
expect(ctx.root, '/');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,16 @@ export 'src/ticket.dart';
|
|||
export 'src/ticket_store.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/kanban_init_hook.dart';
|
||||
|
||||
/// Registers all Kanban commands and init hooks into [registry].
|
||||
void registerCommands(CommandRegistry registry) {
|
||||
registry.register(KanbanCommand());
|
||||
registry.registerInitHook(KanbanInitHook());
|
||||
void registerCommands(
|
||||
CommandRegistry registry, {
|
||||
FileSystem fs = const LocalFileSystem(),
|
||||
}) {
|
||||
registry.register(KanbanCommand(fs: fs));
|
||||
registry.registerInitHook(KanbanInitHook(fs: fs));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../ticket_store.dart';
|
||||
|
||||
class AddCommentCommand extends DewCommand with DewToolCommand {
|
||||
AddCommentCommand() {
|
||||
final FileSystem _fs;
|
||||
|
||||
AddCommentCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||
argParser
|
||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID (e.g. DEW-0001).')
|
||||
..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 comment = args['comment'] as String;
|
||||
|
||||
final context = await ProjectContext.find();
|
||||
final context = await ProjectContext.find(fs: _fs);
|
||||
final store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: context.config.kanban.prefix,
|
||||
fs: context.fs,
|
||||
);
|
||||
await store.addComment(id, comment);
|
||||
return 'Comment added to $id.';
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../ticket_store.dart';
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
|
|
@ -24,22 +26,22 @@ class ArchiveCommand extends DewCommand with DewToolCommand {
|
|||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||
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 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);
|
||||
if (ticket == null) throw ArgumentError('Ticket $id not found.');
|
||||
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);
|
||||
|
||||
// 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).
|
||||
final srcFile = File(p.join(kanbanDir, ticket.column, '$id.md'));
|
||||
final dstFile = File(p.join(archiveDir.path, '$id.md'));
|
||||
final srcFile = context.fs.file(p.join(kanbanDir, ticket.column, '$id.md'));
|
||||
final dstFile = context.fs.file(p.join(archiveDir.path, '$id.md'));
|
||||
await srcFile.rename(dstFile.path);
|
||||
|
||||
return 'Archived $id.';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
|
|
@ -6,7 +8,9 @@ import '../ticket.dart';
|
|||
import '../ticket_store.dart';
|
||||
|
||||
class BoardCommand extends DewCommand with DewToolCommand {
|
||||
BoardCommand() {
|
||||
final FileSystem _fs;
|
||||
|
||||
BoardCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||
argParser
|
||||
..addOption('type', abbr: 't', help: 'Filter tickets to this type.')
|
||||
..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 milestoneFilter = args['milestone'] as String?;
|
||||
|
||||
final context = await ProjectContext.find();
|
||||
final context = await ProjectContext.find(fs: _fs);
|
||||
final config = context.config.kanban;
|
||||
final store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: config.prefix,
|
||||
fs: context.fs,
|
||||
);
|
||||
|
||||
var tickets = await store.list();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../ticket_store.dart';
|
||||
|
||||
class CreateCommand extends DewCommand with DewToolCommand {
|
||||
CreateCommand() {
|
||||
final FileSystem _fs;
|
||||
|
||||
CreateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||
argParser
|
||||
..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.')
|
||||
..addOption(
|
||||
|
|
@ -37,7 +41,7 @@ class CreateCommand extends DewCommand with DewToolCommand {
|
|||
|
||||
@override
|
||||
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 title = args['title'] as String;
|
||||
|
|
@ -65,6 +69,7 @@ class CreateCommand extends DewCommand with DewToolCommand {
|
|||
final store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: config.prefix,
|
||||
fs: context.fs,
|
||||
);
|
||||
final ticket = await store.create(
|
||||
title: title,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../ticket_store.dart';
|
||||
|
||||
class DeleteCommand extends DewCommand with DewToolCommand {
|
||||
DeleteCommand() {
|
||||
final FileSystem _fs;
|
||||
|
||||
DeleteCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||
argParser.addOption(
|
||||
'id',
|
||||
abbr: 'i',
|
||||
|
|
@ -26,10 +30,11 @@ class DeleteCommand extends DewCommand with DewToolCommand {
|
|||
@override
|
||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||
final id = (args['id'] as String).toUpperCase();
|
||||
final context = await ProjectContext.find();
|
||||
final context = await ProjectContext.find(fs: _fs);
|
||||
final store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: context.config.kanban.prefix,
|
||||
fs: context.fs,
|
||||
);
|
||||
await store.delete(id);
|
||||
return 'Deleted $id.';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
|
|
@ -6,7 +8,9 @@ import '../ticket.dart';
|
|||
import '../ticket_store.dart';
|
||||
|
||||
class GetCommand extends DewCommand with DewToolCommand {
|
||||
GetCommand() {
|
||||
final FileSystem _fs;
|
||||
|
||||
GetCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||
argParser.addOption(
|
||||
'id',
|
||||
abbr: 'i',
|
||||
|
|
@ -27,10 +31,11 @@ class GetCommand extends DewCommand with DewToolCommand {
|
|||
@override
|
||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||
final id = (args['id'] as String).toUpperCase();
|
||||
final context = await ProjectContext.find();
|
||||
final context = await ProjectContext.find(fs: _fs);
|
||||
final store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: context.config.kanban.prefix,
|
||||
fs: context.fs,
|
||||
);
|
||||
final ticket = await store.findById(id);
|
||||
if (ticket == null) throw ArgumentError('Ticket $id not found.');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
|
||||
class GetConfigCommand extends DewCommand with DewToolCommand {
|
||||
final FileSystem _fs;
|
||||
|
||||
GetConfigCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
|
||||
@override
|
||||
final String name = 'config';
|
||||
|
||||
|
|
@ -13,7 +18,7 @@ class GetConfigCommand extends DewCommand with DewToolCommand {
|
|||
|
||||
@override
|
||||
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 columns = config.columns.map((c) => '${c.id} (${c.name})').join(', ');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
|
|
@ -6,7 +8,9 @@ import '../ticket.dart';
|
|||
import '../ticket_store.dart';
|
||||
|
||||
class LinkCommand extends DewCommand with DewToolCommand {
|
||||
LinkCommand() {
|
||||
final FileSystem _fs;
|
||||
|
||||
LinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||
argParser
|
||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Source 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.');
|
||||
|
||||
final context = await ProjectContext.find();
|
||||
final context = await ProjectContext.find(fs: _fs);
|
||||
final store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: context.config.kanban.prefix,
|
||||
fs: context.fs,
|
||||
);
|
||||
|
||||
await store.linkTickets(id, targetId, type);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
|
|
@ -6,7 +8,9 @@ import '../ticket.dart';
|
|||
import '../ticket_store.dart';
|
||||
|
||||
class ListCommand extends DewCommand with DewToolCommand {
|
||||
ListCommand() {
|
||||
final FileSystem _fs;
|
||||
|
||||
ListCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||
argParser
|
||||
..addOption('column', abbr: 'c', help: 'Filter to tickets in this column.')
|
||||
..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 includeArchived = args['include-archived'] as bool? ?? false;
|
||||
|
||||
final context = await ProjectContext.find();
|
||||
final context = await ProjectContext.find(fs: _fs);
|
||||
final store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: context.config.kanban.prefix,
|
||||
fs: context.fs,
|
||||
);
|
||||
var tickets = await store.list(includeArchived: includeArchived);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../ticket_store.dart';
|
||||
|
||||
class MoveCommand extends DewCommand with DewToolCommand {
|
||||
MoveCommand() {
|
||||
final FileSystem _fs;
|
||||
|
||||
MoveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||
argParser
|
||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket 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 column = args['column'] as String;
|
||||
|
||||
final context = await ProjectContext.find();
|
||||
final context = await ProjectContext.find(fs: _fs);
|
||||
final config = context.config.kanban;
|
||||
|
||||
if (!config.columns.any((c) => c.id == column)) {
|
||||
|
|
@ -38,6 +42,7 @@ class MoveCommand extends DewCommand with DewToolCommand {
|
|||
final store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: config.prefix,
|
||||
fs: context.fs,
|
||||
);
|
||||
|
||||
final ticket = await store.findById(id);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../ticket_store.dart';
|
||||
|
||||
class SearchCommand extends DewCommand with DewToolCommand {
|
||||
SearchCommand() {
|
||||
final FileSystem _fs;
|
||||
|
||||
SearchCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||
argParser
|
||||
..addOption(
|
||||
'query',
|
||||
|
|
@ -38,10 +42,11 @@ class SearchCommand extends DewCommand with DewToolCommand {
|
|||
final milestoneFilter = args['milestone'] as String?;
|
||||
final includeArchived = args['include-archived'] as bool? ?? false;
|
||||
|
||||
final context = await ProjectContext.find();
|
||||
final context = await ProjectContext.find(fs: _fs);
|
||||
final store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: context.config.kanban.prefix,
|
||||
fs: context.fs,
|
||||
);
|
||||
var tickets = await store.list(includeArchived: includeArchived);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../ticket_store.dart';
|
||||
|
||||
class StatsCommand extends DewCommand with DewToolCommand {
|
||||
final FileSystem _fs;
|
||||
|
||||
StatsCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
|
||||
@override
|
||||
final String name = 'stats';
|
||||
|
||||
|
|
@ -16,11 +21,12 @@ class StatsCommand extends DewCommand with DewToolCommand {
|
|||
|
||||
@override
|
||||
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 store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: config.prefix,
|
||||
fs: context.fs,
|
||||
);
|
||||
|
||||
final stats = await store.stats();
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../ticket_store.dart';
|
||||
|
||||
class UnarchiveCommand extends DewCommand with DewToolCommand {
|
||||
UnarchiveCommand() {
|
||||
final FileSystem _fs;
|
||||
|
||||
UnarchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||
argParser
|
||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID to unarchive.')
|
||||
..addOption(
|
||||
|
|
@ -30,11 +32,11 @@ class UnarchiveCommand extends DewCommand with DewToolCommand {
|
|||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||
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 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);
|
||||
if (ticket == null) throw ArgumentError('Ticket $id not found.');
|
||||
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);
|
||||
|
||||
final srcFile = File(p.join(kanbanDir, 'archive', '$id.md'));
|
||||
final dstFile = File(p.join(targetDir.path, '$id.md'));
|
||||
final srcFile = context.fs.file(p.join(kanbanDir, 'archive', '$id.md'));
|
||||
final dstFile = context.fs.file(p.join(targetDir.path, '$id.md'));
|
||||
await srcFile.rename(dstFile.path);
|
||||
|
||||
return 'Restored $id to "$targetColumn".';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../ticket_store.dart';
|
||||
|
||||
class UnlinkCommand extends DewCommand with DewToolCommand {
|
||||
UnlinkCommand() {
|
||||
final FileSystem _fs;
|
||||
|
||||
UnlinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||
argParser
|
||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.')
|
||||
..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 targetId = (args['target'] as String).toUpperCase();
|
||||
|
||||
final context = await ProjectContext.find();
|
||||
final context = await ProjectContext.find(fs: _fs);
|
||||
final store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: context.config.kanban.prefix,
|
||||
fs: context.fs,
|
||||
);
|
||||
|
||||
await store.unlinkTickets(id, targetId);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import '../kanban_config.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../ticket_store.dart';
|
||||
|
||||
class UpdateCommand extends DewCommand with DewToolCommand {
|
||||
UpdateCommand() {
|
||||
final FileSystem _fs;
|
||||
|
||||
UpdateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||
argParser
|
||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.')
|
||||
..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;
|
||||
|
||||
if (typeId != null && !config.ticketTypes.any((t) => t.id == typeId)) {
|
||||
|
|
@ -77,6 +81,7 @@ class UpdateCommand extends DewCommand with DewToolCommand {
|
|||
final store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: config.prefix,
|
||||
fs: context.fs,
|
||||
);
|
||||
final ticket = await store.update(
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
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/archive_command.dart';
|
||||
|
|
@ -18,22 +20,22 @@ import 'commands/update_command.dart';
|
|||
|
||||
/// Top-level CLI command for all Kanban board operations.
|
||||
class KanbanCommand extends DewCommand {
|
||||
KanbanCommand() {
|
||||
addSubcommand(CreateCommand());
|
||||
addSubcommand(ListCommand());
|
||||
addSubcommand(BoardCommand());
|
||||
addSubcommand(GetCommand());
|
||||
addSubcommand(UpdateCommand());
|
||||
addSubcommand(DeleteCommand());
|
||||
addSubcommand(ArchiveCommand());
|
||||
addSubcommand(UnarchiveCommand());
|
||||
addSubcommand(MoveCommand());
|
||||
addSubcommand(SearchCommand());
|
||||
addSubcommand(AddCommentCommand());
|
||||
addSubcommand(GetConfigCommand());
|
||||
addSubcommand(StatsCommand());
|
||||
addSubcommand(LinkCommand());
|
||||
addSubcommand(UnlinkCommand());
|
||||
KanbanCommand({FileSystem fs = const LocalFileSystem()}) {
|
||||
addSubcommand(CreateCommand(fs: fs));
|
||||
addSubcommand(ListCommand(fs: fs));
|
||||
addSubcommand(BoardCommand(fs: fs));
|
||||
addSubcommand(GetCommand(fs: fs));
|
||||
addSubcommand(UpdateCommand(fs: fs));
|
||||
addSubcommand(DeleteCommand(fs: fs));
|
||||
addSubcommand(ArchiveCommand(fs: fs));
|
||||
addSubcommand(UnarchiveCommand(fs: fs));
|
||||
addSubcommand(MoveCommand(fs: fs));
|
||||
addSubcommand(SearchCommand(fs: fs));
|
||||
addSubcommand(AddCommentCommand(fs: fs));
|
||||
addSubcommand(GetConfigCommand(fs: fs));
|
||||
addSubcommand(StatsCommand(fs: fs));
|
||||
addSubcommand(LinkCommand(fs: fs));
|
||||
addSubcommand(UnlinkCommand(fs: fs));
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import 'dart:io';
|
||||
|
||||
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 'kanban_config.dart';
|
||||
|
||||
class KanbanInitHook implements DewInitHook {
|
||||
final FileSystem _fs;
|
||||
|
||||
KanbanInitHook({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
|
||||
|
||||
@override
|
||||
Future<void> onInit(
|
||||
String projectRoot,
|
||||
|
|
@ -26,7 +30,7 @@ class KanbanInitHook implements DewInitHook {
|
|||
}
|
||||
|
||||
Future<void> _createDir(String path, bool gitkeep) async {
|
||||
final dir = Directory(path);
|
||||
final dir = _fs.directory(path);
|
||||
final existed = await dir.exists();
|
||||
await dir.create(recursive: true);
|
||||
final rel = '.project/kanban/${p.basename(path)}';
|
||||
|
|
@ -35,7 +39,7 @@ class KanbanInitHook implements DewInitHook {
|
|||
} else {
|
||||
print(' created $rel/');
|
||||
if (gitkeep) {
|
||||
await File(p.join(path, '.gitkeep')).writeAsString('');
|
||||
await _fs.file(p.join(path, '.gitkeep')).writeAsString('');
|
||||
print(' created $rel/.gitkeep');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 'ticket.dart';
|
||||
|
|
@ -7,8 +7,13 @@ import 'ticket.dart';
|
|||
class TicketStore {
|
||||
final String kanbanDir;
|
||||
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({
|
||||
required String title,
|
||||
|
|
@ -18,7 +23,7 @@ class TicketStore {
|
|||
List<String> milestones = const [],
|
||||
List<String> labels = const [],
|
||||
}) async {
|
||||
final columnDir = Directory(p.join(kanbanDir, column));
|
||||
final columnDir = fs.directory(p.join(kanbanDir, column));
|
||||
await columnDir.create(recursive: true);
|
||||
final id = _formatId(await _nextNumber());
|
||||
final ticket = Ticket(
|
||||
|
|
@ -32,7 +37,7 @@ class TicketStore {
|
|||
milestones: milestones,
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +48,7 @@ class TicketStore {
|
|||
}
|
||||
|
||||
Future<List<Ticket>> list({bool includeArchived = false}) async {
|
||||
final dir = Directory(kanbanDir);
|
||||
final dir = fs.directory(kanbanDir);
|
||||
if (!await dir.exists()) return const [];
|
||||
final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-\d{4}\.md$');
|
||||
final tickets = <Ticket>[];
|
||||
|
|
@ -168,9 +173,9 @@ class TicketStore {
|
|||
if (column != null && column != ticket.column) {
|
||||
// Column changed — move the file to the new column directory.
|
||||
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 File(p.join(newColDir.path, '$id.md')).writeAsString(updated.toFileContent());
|
||||
await fs.file(p.join(newColDir.path, '$id.md')).writeAsString(updated.toFileContent());
|
||||
} else {
|
||||
await found.file.writeAsString(updated.toFileContent());
|
||||
}
|
||||
|
|
@ -182,26 +187,26 @@ class TicketStore {
|
|||
if (found == null) throw ArgumentError('Ticket $id not found.');
|
||||
await found.file.delete();
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// Searches all column subdirectories (one level deep) for a ticket file.
|
||||
/// Skips the [attachments] directory. Includes [archive].
|
||||
Future<({File file, String column})?> _findTicketFile(String id) async {
|
||||
final dir = Directory(kanbanDir);
|
||||
final dir = fs.directory(kanbanDir);
|
||||
if (!await dir.exists()) return null;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is! Directory) 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));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<int> _nextNumber() async {
|
||||
final dir = Directory(kanbanDir);
|
||||
final dir = fs.directory(kanbanDir);
|
||||
if (!await dir.exists()) return 1;
|
||||
final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$');
|
||||
var max = 0;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ environment:
|
|||
dependencies:
|
||||
dew_core:
|
||||
path: ../core
|
||||
file: ^7.0.1
|
||||
path: ^1.9.0
|
||||
yaml: ^3.1.0
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,31 @@
|
|||
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:file/memory.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() {
|
||||
group('KanbanCommand', () {
|
||||
test('has correct name and description', () {
|
||||
|
|
@ -75,28 +96,9 @@ void main() {
|
|||
});
|
||||
|
||||
test('create and list tools have working handlers', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp('kanban_tool_test_');
|
||||
final origDir = Directory.current;
|
||||
try {
|
||||
await Directory(p.join(tempDir.path, '.project', 'kanban')).create(recursive: true);
|
||||
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 fs = _makeFs();
|
||||
final registry = CommandRegistry();
|
||||
registerCommands(registry);
|
||||
registerCommands(registry, fs: fs);
|
||||
final tools = {for (final t in registry.mcpTools) t.name: t};
|
||||
|
||||
final result = await tools['kanban_create_ticket']!.handler({
|
||||
|
|
@ -124,20 +126,14 @@ dew:
|
|||
expect(statsResult, contains('Total: 1'));
|
||||
expect(statsResult, contains('todo: 1'));
|
||||
expect(statsResult, contains('task: 1'));
|
||||
} finally {
|
||||
Directory.current = origDir;
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('MoveCommand transition validation', () {
|
||||
test('move respects allowed_transitions when configured', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp('kanban_transitions_test_');
|
||||
final origDir = Directory.current;
|
||||
try {
|
||||
await Directory(p.join(tempDir.path, '.project', 'kanban')).create(recursive: true);
|
||||
await File(p.join(tempDir.path, '.project', 'dew.yaml')).writeAsString('''
|
||||
final fs = MemoryFileSystem();
|
||||
fs.directory('/.project/kanban').createSync(recursive: true);
|
||||
fs.file('/.project/dew.yaml').writeAsStringSync('''
|
||||
dew:
|
||||
mcp:
|
||||
host: localhost
|
||||
|
|
@ -163,9 +159,8 @@ dew:
|
|||
name: Done
|
||||
color: green
|
||||
''');
|
||||
Directory.current = tempDir;
|
||||
final registry = CommandRegistry();
|
||||
registerCommands(registry);
|
||||
registerCommands(registry, fs: fs);
|
||||
final tools = {for (final t in registry.mcpTools) t.name: t};
|
||||
|
||||
await tools['kanban_create_ticket']!.handler({
|
||||
|
|
@ -179,10 +174,6 @@ dew:
|
|||
completes,
|
||||
);
|
||||
|
||||
// Disallowed: doing → done is allowed, but doing → backlog is also
|
||||
// allowed; skip to testing a rejected transition:
|
||||
// "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'});
|
||||
|
||||
|
|
@ -201,10 +192,6 @@ dew:
|
|||
'column': 'backlog',
|
||||
});
|
||||
expect(result, contains('T-0001'));
|
||||
} finally {
|
||||
Directory.current = origDir;
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -313,42 +300,30 @@ dew:
|
|||
});
|
||||
|
||||
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'),
|
||||
TicketStore makeStore(MemoryFileSystem fs) => TicketStore(
|
||||
kanbanDir: '/kanban',
|
||||
prefix: 'TEST',
|
||||
fs: fs,
|
||||
);
|
||||
|
||||
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',
|
||||
);
|
||||
final fs = MemoryFileSystem();
|
||||
final store = makeStore(fs);
|
||||
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();
|
||||
final fs = MemoryFileSystem();
|
||||
final store = makeStore(fs);
|
||||
expect(await store.findById('TEST-0099'), isNull);
|
||||
});
|
||||
|
||||
test('create and list milestones/labels persist via store', () async {
|
||||
final store = makeStore();
|
||||
final fs = MemoryFileSystem();
|
||||
final store = makeStore(fs);
|
||||
await store.create(
|
||||
title: 'Tagged',
|
||||
type: 'task',
|
||||
|
|
@ -365,7 +340,8 @@ dew:
|
|||
});
|
||||
|
||||
test('update patches milestones and labels', () async {
|
||||
final store = makeStore();
|
||||
final fs = MemoryFileSystem();
|
||||
final store = makeStore(fs);
|
||||
await store.create(
|
||||
title: 'Tagged',
|
||||
type: 'task',
|
||||
|
|
@ -379,7 +355,8 @@ dew:
|
|||
});
|
||||
|
||||
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: 'B', type: 'task', column: 'todo');
|
||||
final all = await store.list();
|
||||
|
|
@ -387,14 +364,13 @@ dew:
|
|||
});
|
||||
|
||||
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');
|
||||
// Manually move to archive dir to simulate archived state.
|
||||
final kanbanDir = Directory(p.join(tempDir.path, 'kanban'));
|
||||
final archiveDir = Directory(p.join(kanbanDir.path, 'archive'));
|
||||
await archiveDir.create(recursive: true);
|
||||
final src = File(p.join(kanbanDir.path, 'todo', 'TEST-0001.md'));
|
||||
await src.rename(p.join(archiveDir.path, 'TEST-0001.md'));
|
||||
fs.directory('/kanban/archive').createSync(recursive: true);
|
||||
final src = fs.file('/kanban/todo/TEST-0001.md');
|
||||
await src.rename('/kanban/archive/TEST-0001.md');
|
||||
|
||||
expect(await store.list(), isEmpty);
|
||||
final withArchive = await store.list(includeArchived: true);
|
||||
|
|
@ -403,7 +379,8 @@ dew:
|
|||
});
|
||||
|
||||
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');
|
||||
final updated = await store.update('TEST-0001', title: 'New');
|
||||
expect(updated.title, 'New');
|
||||
|
|
@ -411,7 +388,8 @@ dew:
|
|||
});
|
||||
|
||||
test('update throws for missing ticket', () async {
|
||||
final store = makeStore();
|
||||
final fs = MemoryFileSystem();
|
||||
final store = makeStore(fs);
|
||||
expect(
|
||||
() => store.update('TEST-0099', title: 'X'),
|
||||
throwsA(isA<ArgumentError>()),
|
||||
|
|
@ -419,14 +397,16 @@ dew:
|
|||
});
|
||||
|
||||
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.delete('TEST-0001');
|
||||
expect(await store.findById('TEST-0001'), isNull);
|
||||
});
|
||||
|
||||
test('delete throws for missing ticket', () async {
|
||||
final store = makeStore();
|
||||
final fs = MemoryFileSystem();
|
||||
final store = makeStore(fs);
|
||||
expect(
|
||||
() => store.delete('TEST-0099'),
|
||||
throwsA(isA<ArgumentError>()),
|
||||
|
|
@ -434,7 +414,8 @@ dew:
|
|||
});
|
||||
|
||||
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: 'B', type: 'task', column: 'todo');
|
||||
await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks');
|
||||
|
|
@ -457,7 +438,8 @@ dew:
|
|||
});
|
||||
|
||||
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: 'B', type: 'task', column: 'todo');
|
||||
await store.linkTickets('TEST-0001', 'TEST-0002', 'relates_to');
|
||||
|
|
@ -469,7 +451,8 @@ dew:
|
|||
});
|
||||
|
||||
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: 'Story', type: 'task', column: 'todo');
|
||||
await store.linkTickets('TEST-0001', 'TEST-0002', 'parent_of');
|
||||
|
|
@ -481,13 +464,15 @@ dew:
|
|||
});
|
||||
|
||||
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');
|
||||
// Self-link guard is in the command layer, not the store.
|
||||
});
|
||||
|
||||
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: 'B', type: 'task', column: 'todo');
|
||||
await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks');
|
||||
|
|
@ -500,7 +485,8 @@ dew:
|
|||
});
|
||||
|
||||
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: 'B', type: 'task', column: 'done');
|
||||
await store.create(title: 'C', type: 'bug', column: 'todo');
|
||||
|
|
@ -512,7 +498,4 @@ dew:
|
|||
expect((s['byType'] as Map)['bug'], 1);
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.5.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ workspace:
|
|||
- packages/mcp
|
||||
|
||||
dev_dependencies:
|
||||
file: ^7.0.1
|
||||
lints: ^6.0.0
|
||||
melos: ^7.0.0
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue