diff --git a/packages/kanban/lib/src/commands/get_command.dart b/packages/kanban/lib/src/commands/get_command.dart index 71d7a95..5f599fe 100644 --- a/packages/kanban/lib/src/commands/get_command.dart +++ b/packages/kanban/lib/src/commands/get_command.dart @@ -40,6 +40,13 @@ class GetCommand extends DewCommand with DewToolCommand { final buf = StringBuffer(); buf.writeln('[${t.id}] (${t.type}) [${t.column}] ${t.title}'); buf.writeln('Created: ${t.created.toLocal().toString().split('.').first}'); + if (t.links.isNotEmpty) { + buf.writeln(); + buf.writeln('Links:'); + for (final link in t.links) { + buf.writeln(' ${link.type.replaceAll('_', ' ')}: ${link.targetId}'); + } + } if (t.body.isNotEmpty) { buf.writeln(); buf.writeln(t.body); diff --git a/packages/kanban/lib/src/commands/link_command.dart b/packages/kanban/lib/src/commands/link_command.dart index ecf912b..57ca22a 100644 --- a/packages/kanban/lib/src/commands/link_command.dart +++ b/packages/kanban/lib/src/commands/link_command.dart @@ -1,20 +1,30 @@ import 'package:dew_core/dew_core.dart'; import 'package:path/path.dart' as p; +import '../ticket.dart'; import '../ticket_store.dart'; class LinkCommand extends DewCommand with DewToolCommand { LinkCommand() { argParser ..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.') - ..addOption('target', abbr: 't', mandatory: true, help: 'Target ticket ID to link to.'); + ..addOption('target', abbr: 't', mandatory: true, help: 'Target ticket ID.') + ..addOption( + 'type', + abbr: 'y', + mandatory: true, + allowed: linkTypeInverses.keys.toList(), + help: 'Relationship type (e.g. blocks, relates_to, parent_of).', + ); } @override final String name = 'link'; @override - final String description = 'Link two tickets together (e.g. to track dependencies).'; + final String description = + 'Link two tickets with a typed relationship. ' + 'The inverse link is automatically added to the target ticket.'; @override final String toolName = 'kanban_link_tickets'; @@ -23,6 +33,7 @@ class LinkCommand extends DewCommand with DewToolCommand { Future callAsTool(Map args) async { final id = (args['id'] as String).toUpperCase(); final targetId = (args['target'] as String).toUpperCase(); + final type = args['type'] as String; if (id == targetId) throw ArgumentError('A ticket cannot be linked to itself.'); @@ -32,7 +43,8 @@ class LinkCommand extends DewCommand with DewToolCommand { prefix: context.config.kanban.prefix, ); - await store.linkTickets(id, targetId); - return 'Linked $id → $targetId.'; + await store.linkTickets(id, targetId, type); + final inverse = linkTypeInverses[type]!; + return 'Linked $id –[$type]→ $targetId (and $targetId –[$inverse]→ $id).'; } } diff --git a/packages/kanban/lib/src/ticket.dart b/packages/kanban/lib/src/ticket.dart index 6f31555..13e64cb 100644 --- a/packages/kanban/lib/src/ticket.dart +++ b/packages/kanban/lib/src/ticket.dart @@ -1,5 +1,33 @@ import 'package:yaml/yaml.dart'; +/// The valid relationship types between tickets, and their inverses. +const Map linkTypeInverses = { + 'blocks': 'is_blocked_by', + 'is_blocked_by': 'blocks', + 'relates_to': 'relates_to', + 'duplicates': 'is_duplicated_by', + 'is_duplicated_by': 'duplicates', + 'parent_of': 'child_of', + 'child_of': 'parent_of', +}; + +/// A typed, directed link from one ticket to another. +class TicketLink { + final String targetId; + + /// One of the keys in [linkTypeInverses]. + final String type; + + const TicketLink({required this.targetId, required this.type}); + + @override + bool operator ==(Object other) => + other is TicketLink && other.targetId == targetId && other.type == type; + + @override + int get hashCode => Object.hash(targetId, type); +} + class Ticket { final String id; final String title; @@ -9,8 +37,8 @@ class Ticket { final String body; final List comments; - /// IDs of tickets this ticket is linked to (e.g. dependencies). - final List links; + /// Typed links to other tickets. + final List links; const Ticket({ required this.id, @@ -29,7 +57,7 @@ class Ticket { String? column, String? body, List? comments, - List? links, + List? links, }) => Ticket( id: id, title: title ?? this.title, @@ -68,7 +96,8 @@ class Ticket { if (links.isNotEmpty) { buf.writeln('links:'); for (final link in links) { - buf.writeln(' - $link'); + buf.writeln(' - id: ${link.targetId}'); + buf.writeln(' type: ${link.type}'); } } buf.writeln('---'); @@ -101,6 +130,18 @@ class Ticket { final sections = rest.split('\n---\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); + final rawLinks = fm['links'] as YamlList?; + final links = rawLinks + ?.map((entry) { + final map = entry as YamlMap; + return TicketLink( + targetId: map['id'] as String, + type: map['type'] as String, + ); + }) + .toList() ?? + const []; + return Ticket( id: id, title: fm['title'] as String, @@ -109,7 +150,8 @@ class Ticket { created: DateTime.parse(fm['created'] as String), body: sections.isNotEmpty ? sections[0] : '', comments: sections.length > 1 ? sections.sublist(1) : const [], - links: (fm['links'] as YamlList?)?.cast().toList() ?? const [], + links: links, ); } } + diff --git a/packages/kanban/lib/src/ticket_store.dart b/packages/kanban/lib/src/ticket_store.dart index d82f03e..ffdc750 100644 --- a/packages/kanban/lib/src/ticket_store.dart +++ b/packages/kanban/lib/src/ticket_store.dart @@ -64,26 +64,58 @@ class TicketStore { return updated; } - Future linkTickets(String id, String targetId) async { + Future linkTickets(String id, String targetId, String type) async { + if (!linkTypeInverses.containsKey(type)) { + throw ArgumentError( + 'Unknown link type "$type". ' + 'Valid: ${linkTypeInverses.keys.join(', ')}', + ); + } final ticket = await findById(id); if (ticket == null) throw ArgumentError('Ticket $id not found.'); - if (await findById(targetId) == null) { - throw ArgumentError('Ticket $targetId not found.'); + final target = await findById(targetId); + if (target == null) throw ArgumentError('Ticket $targetId not found.'); + + // Forward link (idempotent — skip if already linked to same target). + if (!ticket.links.any((l) => l.targetId == targetId)) { + final updated = ticket.copyWith( + links: [...ticket.links, TicketLink(targetId: targetId, type: type)], + ); + await File(_filePath(id)).writeAsString(updated.toFileContent()); } - if (ticket.links.contains(targetId)) return ticket; - final updated = ticket.copyWith(links: [...ticket.links, targetId]); - await File(_filePath(id)).writeAsString(updated.toFileContent()); - return updated; + + // Inverse link on the target. + final inverseType = linkTypeInverses[type]!; + if (!target.links.any((l) => l.targetId == id)) { + final updatedTarget = target.copyWith( + links: [...target.links, TicketLink(targetId: id, type: inverseType)], + ); + await File(_filePath(targetId)).writeAsString(updatedTarget.toFileContent()); + } + + return (await findById(id))!; } Future unlinkTickets(String id, String targetId) async { final ticket = await findById(id); if (ticket == null) throw ArgumentError('Ticket $id not found.'); + + // Remove forward link. final updated = ticket.copyWith( - links: ticket.links.where((l) => l != targetId).toList(), + links: ticket.links.where((l) => l.targetId != targetId).toList(), ); await File(_filePath(id)).writeAsString(updated.toFileContent()); - return updated; + + // Remove inverse link on target (if it exists). + final target = await findById(targetId); + if (target != null) { + final updatedTarget = target.copyWith( + links: target.links.where((l) => l.targetId != id).toList(), + ); + await File(_filePath(targetId)).writeAsString(updatedTarget.toFileContent()); + } + + return (await findById(id))!; } /// Returns counts of tickets grouped by column and type. diff --git a/packages/kanban/test/dew_kanban_test.dart b/packages/kanban/test/dew_kanban_test.dart index 3d5346f..fb8d323 100644 --- a/packages/kanban/test/dew_kanban_test.dart +++ b/packages/kanban/test/dew_kanban_test.dart @@ -173,10 +173,17 @@ dew: created: DateTime.utc(2026, 1, 3), body: '', comments: const [], - links: ['TEST-0001', 'TEST-0002'], + links: [ + TicketLink(targetId: 'TEST-0001', type: 'blocks'), + TicketLink(targetId: 'TEST-0002', type: 'relates_to'), + ], ); final parsed = Ticket.fromFileContent(t.id, t.toFileContent()); - expect(parsed.links, ['TEST-0001', 'TEST-0002']); + expect(parsed.links, hasLength(2)); + expect(parsed.links[0].targetId, 'TEST-0001'); + expect(parsed.links[0].type, 'blocks'); + expect(parsed.links[1].targetId, 'TEST-0002'); + expect(parsed.links[1].type, 'relates_to'); }); test('no links field when links is empty', () { @@ -267,34 +274,70 @@ dew: ); }); - test('linkTickets adds link and is idempotent', () async { + test('linkTickets adds typed link bidirectionally and is idempotent', () async { final store = makeStore(); await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo'); - await store.linkTickets('TEST-0001', 'TEST-0002'); - final t = await store.findById('TEST-0001'); - expect(t!.links, contains('TEST-0002')); - // idempotent - await store.linkTickets('TEST-0001', 'TEST-0002'); - final t2 = await store.findById('TEST-0001'); - expect(t2!.links, hasLength(1)); + await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks'); + + final a = await store.findById('TEST-0001'); + expect(a!.links, hasLength(1)); + expect(a.links.first.targetId, 'TEST-0002'); + expect(a.links.first.type, 'blocks'); + + // Inverse written on target. + final b = await store.findById('TEST-0002'); + expect(b!.links, hasLength(1)); + expect(b.links.first.targetId, 'TEST-0001'); + expect(b.links.first.type, 'is_blocked_by'); + + // Idempotent — calling again doesn't add duplicates. + await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks'); + final a2 = await store.findById('TEST-0001'); + expect(a2!.links, hasLength(1)); + }); + + test('linkTickets relates_to is symmetric', () async { + final store = makeStore(); + await store.create(title: 'A', type: 'task', column: 'todo'); + await store.create(title: 'B', type: 'task', column: 'todo'); + await store.linkTickets('TEST-0001', 'TEST-0002', 'relates_to'); + + final a = await store.findById('TEST-0001'); + final b = await store.findById('TEST-0002'); + expect(a!.links.first.type, 'relates_to'); + expect(b!.links.first.type, 'relates_to'); + }); + + test('linkTickets parent_of / child_of inverse pair', () async { + final store = makeStore(); + await store.create(title: 'Epic', type: 'task', column: 'todo'); + await store.create(title: 'Story', type: 'task', column: 'todo'); + await store.linkTickets('TEST-0001', 'TEST-0002', 'parent_of'); + + final parent = await store.findById('TEST-0001'); + final child = await store.findById('TEST-0002'); + expect(parent!.links.first.type, 'parent_of'); + expect(child!.links.first.type, 'child_of'); }); test('linkTickets throws for self-link via command', () async { final store = makeStore(); await store.create(title: 'A', type: 'task', column: 'todo'); - // Self-link guard is in the command layer, not the store — store allows it. - // Test the command layer check separately via tooling. + // Self-link guard is in the command layer, not the store. }); - test('unlinkTickets removes link', () async { + test('unlinkTickets removes link on both sides', () async { final store = makeStore(); await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo'); - await store.linkTickets('TEST-0001', 'TEST-0002'); + await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks'); await store.unlinkTickets('TEST-0001', 'TEST-0002'); - final t = await store.findById('TEST-0001'); - expect(t!.links, isEmpty); + + final a = await store.findById('TEST-0001'); + final b = await store.findById('TEST-0002'); + expect(a!.links, isEmpty); + expect(b!.links, isEmpty); }); test('stats returns correct counts', () async {