Archive command and include-archived flag (DEW-0015)
- ArchiveCommand (kanban archive --id <id>): moves ticket file from its current column dir to .project/kanban/archive/; attachments stay put (under attachments/<id>/); registered as 'kanban_archive_ticket' MCP tool - TicketStore.list() gains includeArchived param (default false); archive/ dir skipped unless includeArchived=true - ListCommand: --include-archived flag - SearchCommand: --include-archived flag - Test: list excludes/includes archive correctly - 14 MCP tools; 'archive' added to expected subcommands list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
5d8451383b
commit
ade243e2c7
9 changed files with 79 additions and 8 deletions
47
packages/kanban/lib/src/commands/archive_command.dart
Normal file
47
packages/kanban/lib/src/commands/archive_command.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
import '../kanban_config.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import '../ticket_store.dart';
|
||||||
|
|
||||||
|
class ArchiveCommand extends DewCommand with DewToolCommand {
|
||||||
|
ArchiveCommand() {
|
||||||
|
argParser.addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID to archive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String name = 'archive';
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String description = 'Archive a ticket (moves it to the archive column).';
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String toolName = 'kanban_archive_ticket';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
|
final id = (args['id'] as String).toUpperCase();
|
||||||
|
|
||||||
|
final context = await ProjectContext.find();
|
||||||
|
final config = context.config.kanban;
|
||||||
|
final kanbanDir = p.join(context.root, '.project', 'kanban');
|
||||||
|
|
||||||
|
final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix);
|
||||||
|
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'));
|
||||||
|
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'));
|
||||||
|
await srcFile.rename(dstFile.path);
|
||||||
|
|
||||||
|
return 'Archived $id.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,8 @@ class ListCommand extends DewCommand with DewToolCommand {
|
||||||
..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.')
|
||||||
..addOption('label', help: 'Filter to tickets with this label.')
|
..addOption('label', help: 'Filter to tickets with this label.')
|
||||||
..addOption('milestone', help: 'Filter to tickets in this milestone.');
|
..addOption('milestone', help: 'Filter to tickets in this milestone.')
|
||||||
|
..addFlag('include-archived', help: 'Include archived tickets.', negatable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -29,13 +30,14 @@ class ListCommand extends DewCommand with DewToolCommand {
|
||||||
final typeFilter = args['type'] as String?;
|
final typeFilter = args['type'] as String?;
|
||||||
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 includeArchived = args['include-archived'] as bool? ?? false;
|
||||||
|
|
||||||
final context = await ProjectContext.find();
|
final context = await ProjectContext.find();
|
||||||
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,
|
||||||
);
|
);
|
||||||
var tickets = await store.list();
|
var tickets = await store.list(includeArchived: includeArchived);
|
||||||
|
|
||||||
if (columnFilter != null) {
|
if (columnFilter != null) {
|
||||||
tickets = tickets.where((t) => t.column == columnFilter).toList();
|
tickets = tickets.where((t) => t.column == columnFilter).toList();
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ class SearchCommand extends DewCommand with DewToolCommand {
|
||||||
..addOption('column', abbr: 'c', help: 'Restrict search to this column.')
|
..addOption('column', abbr: 'c', help: 'Restrict search to this column.')
|
||||||
..addOption('type', abbr: 't', help: 'Restrict search to this ticket type.')
|
..addOption('type', abbr: 't', help: 'Restrict search to this ticket type.')
|
||||||
..addOption('label', help: 'Restrict search to tickets with this label.')
|
..addOption('label', help: 'Restrict search to tickets with this label.')
|
||||||
..addOption('milestone', help: 'Restrict search to tickets in this milestone.');
|
..addOption('milestone', help: 'Restrict search to tickets in this milestone.')
|
||||||
|
..addFlag('include-archived', help: 'Include archived tickets.', negatable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -35,13 +36,14 @@ class SearchCommand extends DewCommand with DewToolCommand {
|
||||||
final typeFilter = args['type'] as String?;
|
final typeFilter = args['type'] as String?;
|
||||||
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 includeArchived = args['include-archived'] as bool? ?? false;
|
||||||
|
|
||||||
final context = await ProjectContext.find();
|
final context = await ProjectContext.find();
|
||||||
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,
|
||||||
);
|
);
|
||||||
var tickets = await store.list();
|
var tickets = await store.list(includeArchived: includeArchived);
|
||||||
|
|
||||||
if (columnFilter != null) {
|
if (columnFilter != null) {
|
||||||
tickets = tickets.where((t) => t.column == columnFilter).toList();
|
tickets = tickets.where((t) => t.column == columnFilter).toList();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
|
||||||
import 'commands/add_comment_command.dart';
|
import 'commands/add_comment_command.dart';
|
||||||
|
import 'commands/archive_command.dart';
|
||||||
import 'commands/board_command.dart';
|
import 'commands/board_command.dart';
|
||||||
import 'commands/create_command.dart';
|
import 'commands/create_command.dart';
|
||||||
import 'commands/delete_command.dart';
|
import 'commands/delete_command.dart';
|
||||||
|
|
@ -23,6 +24,7 @@ class KanbanCommand extends DewCommand {
|
||||||
addSubcommand(GetCommand());
|
addSubcommand(GetCommand());
|
||||||
addSubcommand(UpdateCommand());
|
addSubcommand(UpdateCommand());
|
||||||
addSubcommand(DeleteCommand());
|
addSubcommand(DeleteCommand());
|
||||||
|
addSubcommand(ArchiveCommand());
|
||||||
addSubcommand(MoveCommand());
|
addSubcommand(MoveCommand());
|
||||||
addSubcommand(SearchCommand());
|
addSubcommand(SearchCommand());
|
||||||
addSubcommand(AddCommentCommand());
|
addSubcommand(AddCommentCommand());
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class TicketStore {
|
||||||
return Ticket.fromFileContent(id, await found.file.readAsString(), found.column);
|
return Ticket.fromFileContent(id, await found.file.readAsString(), found.column);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Ticket>> list() async {
|
Future<List<Ticket>> list({bool includeArchived = false}) async {
|
||||||
final dir = Directory(kanbanDir);
|
final dir = 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$');
|
||||||
|
|
@ -50,7 +50,8 @@ class TicketStore {
|
||||||
await for (final entity in dir.list()) {
|
await for (final entity in dir.list()) {
|
||||||
if (entity is! Directory) continue;
|
if (entity is! Directory) continue;
|
||||||
final col = p.basename(entity.path);
|
final col = p.basename(entity.path);
|
||||||
if (col == 'archive' || col == 'attachments') continue;
|
if (col == 'attachments') continue;
|
||||||
|
if (col == 'archive' && !includeArchived) continue;
|
||||||
await for (final file in entity.list()) {
|
await for (final file in entity.list()) {
|
||||||
if (file is! File) continue;
|
if (file is! File) continue;
|
||||||
final name = p.basename(file.path);
|
final name = p.basename(file.path);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ void main() {
|
||||||
expect(
|
expect(
|
||||||
cmd.subcommands.keys,
|
cmd.subcommands.keys,
|
||||||
containsAll([
|
containsAll([
|
||||||
'create', 'list', 'board', 'get', 'update', 'delete',
|
'create', 'list', 'board', 'get', 'update', 'delete', 'archive',
|
||||||
'move', 'search', 'comment', 'config', 'stats', 'link', 'unlink',
|
'move', 'search', 'comment', 'config', 'stats', 'link', 'unlink',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
@ -36,7 +36,7 @@ void main() {
|
||||||
final registry = CommandRegistry();
|
final registry = CommandRegistry();
|
||||||
registerCommands(registry);
|
registerCommands(registry);
|
||||||
final tools = registry.mcpTools;
|
final tools = registry.mcpTools;
|
||||||
expect(tools, hasLength(13));
|
expect(tools, hasLength(14));
|
||||||
final names = tools.map((t) => t.name).toSet();
|
final names = tools.map((t) => t.name).toSet();
|
||||||
expect(names, {
|
expect(names, {
|
||||||
'kanban_create_ticket',
|
'kanban_create_ticket',
|
||||||
|
|
@ -45,6 +45,7 @@ void main() {
|
||||||
'kanban_get_ticket',
|
'kanban_get_ticket',
|
||||||
'kanban_update_ticket',
|
'kanban_update_ticket',
|
||||||
'kanban_delete_ticket',
|
'kanban_delete_ticket',
|
||||||
|
'kanban_archive_ticket',
|
||||||
'kanban_move_ticket',
|
'kanban_move_ticket',
|
||||||
'kanban_search_tickets',
|
'kanban_search_tickets',
|
||||||
'kanban_add_comment',
|
'kanban_add_comment',
|
||||||
|
|
@ -384,6 +385,22 @@ dew:
|
||||||
expect(all.map((t) => t.id), ['TEST-0001', 'TEST-0002']);
|
expect(all.map((t) => t.id), ['TEST-0001', 'TEST-0002']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('list excludes archive by default, includes with flag', () async {
|
||||||
|
final store = makeStore();
|
||||||
|
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'));
|
||||||
|
|
||||||
|
expect(await store.list(), isEmpty);
|
||||||
|
final withArchive = await store.list(includeArchived: true);
|
||||||
|
expect(withArchive, hasLength(1));
|
||||||
|
expect(withArchive.first.column, 'archive');
|
||||||
|
});
|
||||||
|
|
||||||
test('update patches specified fields', () async {
|
test('update patches specified fields', () async {
|
||||||
final store = makeStore();
|
final store = makeStore();
|
||||||
await store.create(title: 'Old', type: 'task', column: 'todo');
|
await store.create(title: 'Old', type: 'task', column: 'todo');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue