Upgrade ticket links to typed bidirectional relationships
Link types: blocks/is_blocked_by, relates_to (symmetric),
duplicates/is_duplicated_by, parent_of/child_of
- Add TicketLink(targetId, type) class and linkTypeInverses map to
ticket.dart
- Update Ticket.links from List<String> to List<TicketLink>
- Update toFileContent/fromFileContent for new {id, type} YAML format
- Update TicketStore.linkTickets to accept a type, write the forward
link and automatically write the inverse on the target ticket
- Update TicketStore.unlinkTickets to remove both sides
- Update LinkCommand with mandatory --type/-y option (enum of all valid
types); describe inverse in output message
- Update GetCommand to display typed links in ticket output
- Update all tests: typed roundtrip, bidirectional store tests for
blocks, relates_to (symmetric), parent_of/child_of, unlink both sides
- 29 tests pass, dart analyze clean
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
4efa1078ea
commit
08f4d5c7cf
5 changed files with 170 additions and 34 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<String> callAsTool(Map<String, dynamic> 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).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,33 @@
|
|||
import 'package:yaml/yaml.dart';
|
||||
|
||||
/// The valid relationship types between tickets, and their inverses.
|
||||
const Map<String, String> 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<String> comments;
|
||||
|
||||
/// IDs of tickets this ticket is linked to (e.g. dependencies).
|
||||
final List<String> links;
|
||||
/// Typed links to other tickets.
|
||||
final List<TicketLink> links;
|
||||
|
||||
const Ticket({
|
||||
required this.id,
|
||||
|
|
@ -29,7 +57,7 @@ class Ticket {
|
|||
String? column,
|
||||
String? body,
|
||||
List<String>? comments,
|
||||
List<String>? links,
|
||||
List<TicketLink>? 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<String>().toList() ?? const [],
|
||||
links: links,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,26 +64,58 @@ class TicketStore {
|
|||
return updated;
|
||||
}
|
||||
|
||||
Future<Ticket> linkTickets(String id, String targetId) async {
|
||||
Future<Ticket> 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.');
|
||||
}
|
||||
if (ticket.links.contains(targetId)) return ticket;
|
||||
final updated = ticket.copyWith(links: [...ticket.links, targetId]);
|
||||
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());
|
||||
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<Ticket> 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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue