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: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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, '/');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
||||||
|
|
|
||||||
|
|
@ -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(', ');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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".';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue