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('type', abbr: 't', help: 'Filter to tickets of this type.')
|
||||
..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
|
||||
|
|
@ -29,13 +30,14 @@ class ListCommand extends DewCommand with DewToolCommand {
|
|||
final typeFilter = args['type'] as String?;
|
||||
final labelFilter = args['label'] as String?;
|
||||
final milestoneFilter = args['milestone'] as String?;
|
||||
final includeArchived = args['include-archived'] as bool? ?? false;
|
||||
|
||||
final context = await ProjectContext.find();
|
||||
final store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: context.config.kanban.prefix,
|
||||
);
|
||||
var tickets = await store.list();
|
||||
var tickets = await store.list(includeArchived: includeArchived);
|
||||
|
||||
if (columnFilter != null) {
|
||||
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('type', abbr: 't', help: 'Restrict search to this ticket type.')
|
||||
..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
|
||||
|
|
@ -35,13 +36,14 @@ class SearchCommand extends DewCommand with DewToolCommand {
|
|||
final typeFilter = args['type'] as String?;
|
||||
final labelFilter = args['label'] as String?;
|
||||
final milestoneFilter = args['milestone'] as String?;
|
||||
final includeArchived = args['include-archived'] as bool? ?? false;
|
||||
|
||||
final context = await ProjectContext.find();
|
||||
final store = TicketStore(
|
||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||
prefix: context.config.kanban.prefix,
|
||||
);
|
||||
var tickets = await store.list();
|
||||
var tickets = await store.list(includeArchived: includeArchived);
|
||||
|
||||
if (columnFilter != null) {
|
||||
tickets = tickets.where((t) => t.column == columnFilter).toList();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:dew_core/dew_core.dart';
|
||||
|
||||
import 'commands/add_comment_command.dart';
|
||||
import 'commands/archive_command.dart';
|
||||
import 'commands/board_command.dart';
|
||||
import 'commands/create_command.dart';
|
||||
import 'commands/delete_command.dart';
|
||||
|
|
@ -23,6 +24,7 @@ class KanbanCommand extends DewCommand {
|
|||
addSubcommand(GetCommand());
|
||||
addSubcommand(UpdateCommand());
|
||||
addSubcommand(DeleteCommand());
|
||||
addSubcommand(ArchiveCommand());
|
||||
addSubcommand(MoveCommand());
|
||||
addSubcommand(SearchCommand());
|
||||
addSubcommand(AddCommentCommand());
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class TicketStore {
|
|||
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);
|
||||
if (!await dir.exists()) return const [];
|
||||
final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-\d{4}\.md$');
|
||||
|
|
@ -50,7 +50,8 @@ class TicketStore {
|
|||
await for (final entity in dir.list()) {
|
||||
if (entity is! Directory) continue;
|
||||
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()) {
|
||||
if (file is! File) continue;
|
||||
final name = p.basename(file.path);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ void main() {
|
|||
expect(
|
||||
cmd.subcommands.keys,
|
||||
containsAll([
|
||||
'create', 'list', 'board', 'get', 'update', 'delete',
|
||||
'create', 'list', 'board', 'get', 'update', 'delete', 'archive',
|
||||
'move', 'search', 'comment', 'config', 'stats', 'link', 'unlink',
|
||||
]),
|
||||
);
|
||||
|
|
@ -36,7 +36,7 @@ void main() {
|
|||
final registry = CommandRegistry();
|
||||
registerCommands(registry);
|
||||
final tools = registry.mcpTools;
|
||||
expect(tools, hasLength(13));
|
||||
expect(tools, hasLength(14));
|
||||
final names = tools.map((t) => t.name).toSet();
|
||||
expect(names, {
|
||||
'kanban_create_ticket',
|
||||
|
|
@ -45,6 +45,7 @@ void main() {
|
|||
'kanban_get_ticket',
|
||||
'kanban_update_ticket',
|
||||
'kanban_delete_ticket',
|
||||
'kanban_archive_ticket',
|
||||
'kanban_move_ticket',
|
||||
'kanban_search_tickets',
|
||||
'kanban_add_comment',
|
||||
|
|
@ -384,6 +385,22 @@ dew:
|
|||
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 {
|
||||
final store = makeStore();
|
||||
await store.create(title: 'Old', type: 'task', column: 'todo');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue