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:
Chris Hendrickson 2026-04-23 20:07:50 -04:00
parent 5d8451383b
commit ade243e2c7
9 changed files with 79 additions and 8 deletions

View 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.';
}
}

View file

@ -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();

View file

@ -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();

View file

@ -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());

View file

@ -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);

View file

@ -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');