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();
|
final buf = StringBuffer();
|
||||||
buf.writeln('[${t.id}] (${t.type}) [${t.column}] ${t.title}');
|
buf.writeln('[${t.id}] (${t.type}) [${t.column}] ${t.title}');
|
||||||
buf.writeln('Created: ${t.created.toLocal().toString().split('.').first}');
|
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) {
|
if (t.body.isNotEmpty) {
|
||||||
buf.writeln();
|
buf.writeln();
|
||||||
buf.writeln(t.body);
|
buf.writeln(t.body);
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,30 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import '../ticket.dart';
|
||||||
import '../ticket_store.dart';
|
import '../ticket_store.dart';
|
||||||
|
|
||||||
class LinkCommand extends DewCommand with DewToolCommand {
|
class LinkCommand extends DewCommand with DewToolCommand {
|
||||||
LinkCommand() {
|
LinkCommand() {
|
||||||
argParser
|
argParser
|
||||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.')
|
..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
|
@override
|
||||||
final String name = 'link';
|
final String name = 'link';
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
final String toolName = 'kanban_link_tickets';
|
final String toolName = 'kanban_link_tickets';
|
||||||
|
|
@ -23,6 +33,7 @@ class LinkCommand extends DewCommand with DewToolCommand {
|
||||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
final id = (args['id'] as String).toUpperCase();
|
final id = (args['id'] as String).toUpperCase();
|
||||||
final targetId = (args['target'] 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.');
|
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,
|
prefix: context.config.kanban.prefix,
|
||||||
);
|
);
|
||||||
|
|
||||||
await store.linkTickets(id, targetId);
|
await store.linkTickets(id, targetId, type);
|
||||||
return 'Linked $id → $targetId.';
|
final inverse = linkTypeInverses[type]!;
|
||||||
|
return 'Linked $id –[$type]→ $targetId (and $targetId –[$inverse]→ $id).';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,33 @@
|
||||||
import 'package:yaml/yaml.dart';
|
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 {
|
class Ticket {
|
||||||
final String id;
|
final String id;
|
||||||
final String title;
|
final String title;
|
||||||
|
|
@ -9,8 +37,8 @@ class Ticket {
|
||||||
final String body;
|
final String body;
|
||||||
final List<String> comments;
|
final List<String> comments;
|
||||||
|
|
||||||
/// IDs of tickets this ticket is linked to (e.g. dependencies).
|
/// Typed links to other tickets.
|
||||||
final List<String> links;
|
final List<TicketLink> links;
|
||||||
|
|
||||||
const Ticket({
|
const Ticket({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -29,7 +57,7 @@ class Ticket {
|
||||||
String? column,
|
String? column,
|
||||||
String? body,
|
String? body,
|
||||||
List<String>? comments,
|
List<String>? comments,
|
||||||
List<String>? links,
|
List<TicketLink>? links,
|
||||||
}) => Ticket(
|
}) => Ticket(
|
||||||
id: id,
|
id: id,
|
||||||
title: title ?? this.title,
|
title: title ?? this.title,
|
||||||
|
|
@ -68,7 +96,8 @@ class Ticket {
|
||||||
if (links.isNotEmpty) {
|
if (links.isNotEmpty) {
|
||||||
buf.writeln('links:');
|
buf.writeln('links:');
|
||||||
for (final link in links) {
|
for (final link in links) {
|
||||||
buf.writeln(' - $link');
|
buf.writeln(' - id: ${link.targetId}');
|
||||||
|
buf.writeln(' type: ${link.type}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buf.writeln('---');
|
buf.writeln('---');
|
||||||
|
|
@ -101,6 +130,18 @@ class Ticket {
|
||||||
final sections =
|
final sections =
|
||||||
rest.split('\n---\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
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(
|
return Ticket(
|
||||||
id: id,
|
id: id,
|
||||||
title: fm['title'] as String,
|
title: fm['title'] as String,
|
||||||
|
|
@ -109,7 +150,8 @@ class Ticket {
|
||||||
created: DateTime.parse(fm['created'] as String),
|
created: DateTime.parse(fm['created'] as String),
|
||||||
body: sections.isNotEmpty ? sections[0] : '',
|
body: sections.isNotEmpty ? sections[0] : '',
|
||||||
comments: sections.length > 1 ? sections.sublist(1) : const [],
|
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;
|
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);
|
final ticket = await findById(id);
|
||||||
if (ticket == null) throw ArgumentError('Ticket $id not found.');
|
if (ticket == null) throw ArgumentError('Ticket $id not found.');
|
||||||
if (await findById(targetId) == null) {
|
final target = await findById(targetId);
|
||||||
throw ArgumentError('Ticket $targetId not found.');
|
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]);
|
// Inverse link on the target.
|
||||||
await File(_filePath(id)).writeAsString(updated.toFileContent());
|
final inverseType = linkTypeInverses[type]!;
|
||||||
return updated;
|
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 {
|
Future<Ticket> unlinkTickets(String id, String targetId) async {
|
||||||
final ticket = await findById(id);
|
final ticket = await findById(id);
|
||||||
if (ticket == null) throw ArgumentError('Ticket $id not found.');
|
if (ticket == null) throw ArgumentError('Ticket $id not found.');
|
||||||
|
|
||||||
|
// Remove forward link.
|
||||||
final updated = ticket.copyWith(
|
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());
|
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.
|
/// Returns counts of tickets grouped by column and type.
|
||||||
|
|
|
||||||
|
|
@ -173,10 +173,17 @@ dew:
|
||||||
created: DateTime.utc(2026, 1, 3),
|
created: DateTime.utc(2026, 1, 3),
|
||||||
body: '',
|
body: '',
|
||||||
comments: const [],
|
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());
|
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', () {
|
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();
|
final store = makeStore();
|
||||||
await store.create(title: 'A', type: 'task', column: 'todo');
|
await store.create(title: 'A', type: 'task', column: 'todo');
|
||||||
await store.create(title: 'B', 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');
|
||||||
final t = await store.findById('TEST-0001');
|
|
||||||
expect(t!.links, contains('TEST-0002'));
|
final a = await store.findById('TEST-0001');
|
||||||
// idempotent
|
expect(a!.links, hasLength(1));
|
||||||
await store.linkTickets('TEST-0001', 'TEST-0002');
|
expect(a.links.first.targetId, 'TEST-0002');
|
||||||
final t2 = await store.findById('TEST-0001');
|
expect(a.links.first.type, 'blocks');
|
||||||
expect(t2!.links, hasLength(1));
|
|
||||||
|
// 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 {
|
test('linkTickets throws for self-link via command', () async {
|
||||||
final store = makeStore();
|
final store = makeStore();
|
||||||
await store.create(title: 'A', type: 'task', column: 'todo');
|
await store.create(title: 'A', type: 'task', column: 'todo');
|
||||||
// Self-link guard is in the command layer, not the store — store allows it.
|
// Self-link guard is in the command layer, not the store.
|
||||||
// Test the command layer check separately via tooling.
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unlinkTickets removes link', () async {
|
test('unlinkTickets removes link on both sides', () async {
|
||||||
final store = makeStore();
|
final store = makeStore();
|
||||||
await store.create(title: 'A', type: 'task', column: 'todo');
|
await store.create(title: 'A', type: 'task', column: 'todo');
|
||||||
await store.create(title: 'B', 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');
|
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 {
|
test('stats returns correct counts', () async {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue