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:
Chris Hendrickson 2026-04-23 19:21:25 -04:00
parent 4efa1078ea
commit 08f4d5c7cf
5 changed files with 170 additions and 34 deletions

View file

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

View file

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

View file

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

View file

@ -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.');
}
if (ticket.links.contains(targetId)) return ticket; // Forward link (idempotent skip if already linked to same target).
final updated = ticket.copyWith(links: [...ticket.links, targetId]); 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()); 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 { 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.

View file

@ -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 {