From 75248e9cde1555bfd557841b6ce2da6ed1a0336f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 9 Nov 2026 15:24:34 +0100 Subject: [PATCH] Add new_note to add_task with merge and start new_note files a separate note per repo; an exact section-title match in another open note merges into it and starts it via the board, unless that note is running, none or low priority. Todo: 64d39ab5 Co-Authored-By: Claude Sonnet 5.5 --- lib/cli/cli_args.dart | 11 +++ lib/cli/cli_commands.dart | 29 +++++++ lib/cli/cli_usage.dart | 2 + lib/data/repo_note.dart | 19 ++++- lib/data/repo_note_merge.dart | 7 +- lib/data/repo_note_split.dart | 112 +++++++++++++++++++++++++ lib/mcp/board_client.dart | 100 +++++++++++++++++++++++ lib/mcp/mcp_main.dart | 3 +- lib/mcp/task_service.dart | 24 +++++- lib/mcp/task_split.dart | 48 +++++++++++ lib/mcp/todo_mcp_server.dart | 8 +- test/cli/cli_new_note_test.dart | 40 +++++++++ test/mcp/board_client_test.dart | 130 +++++++++++++++++++++++++++++ test/mcp/mcp_harness.dart | 7 +- test/mcp/mcp_new_note_test.dart | 128 +++++++++++++++++++++++++++++ test/repo_note_split_test.dart | 140 ++++++++++++++++++++++++++++++++ 16 files changed, 799 insertions(+), 9 deletions(-) create mode 100644 lib/data/repo_note_split.dart create mode 100644 lib/mcp/board_client.dart create mode 100644 lib/mcp/task_split.dart create mode 100644 test/cli/cli_new_note_test.dart create mode 100644 test/mcp/board_client_test.dart create mode 100644 test/mcp/mcp_new_note_test.dart create mode 100644 test/repo_note_split_test.dart diff --git a/lib/cli/cli_args.dart b/lib/cli/cli_args.dart index 11c63b7..d40475d 100644 --- a/lib/cli/cli_args.dart +++ b/lib/cli/cli_args.dart @@ -57,6 +57,7 @@ class CliArgs { this.priority = Priority.medium, this.status, this.dataDir, + this.newNote = false, this.opts = ( repo: null, query: null, @@ -79,6 +80,8 @@ class CliArgs { } switch (arg) { + case '--new-note': + opts[arg] = ''; case '-h' || '--help': modes.add(CliMode.help); case '--text' || '--priority' || '--status' || '--data-dir': @@ -121,6 +124,10 @@ class CliArgs { ? null : _byName(Status.values, rawStatus, '--status', _statusName), dataDir: opts['--data-dir'], + newNote: + opts.containsKey('--new-note') && + (opts['--repo'] != null || + (throw UsageError('--new-note needs --repo'))), opts: ( repo: _repo(opts['--repo']), query: _nonBlank(opts['--search'], '--search'), @@ -149,6 +156,9 @@ class CliArgs { /// Overrides the default data dir (a second, independent node). final String? dataDir; + /// `--new-note`: the `--repo` task gets its own note. + final bool newNote; + /// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values. final ({ String? repo, @@ -172,6 +182,7 @@ class CliArgs { {CliMode.add, CliMode.list, CliMode.search}, ), '--evidence': ('to --close', {CliMode.close}), + '--new-note': (_adding, {CliMode.add}), }; static void _checkScopes(Iterable flags, CliMode mode) { diff --git a/lib/cli/cli_commands.dart b/lib/cli/cli_commands.dart index ecca27d..f0df335 100644 --- a/lib/cli/cli_commands.dart +++ b/lib/cli/cli_commands.dart @@ -4,6 +4,7 @@ import 'package:todo/cli/note_lookup.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; import 'package:todo/data/repo_note.dart'; +import 'package:todo/data/repo_note_split.dart'; import 'package:todo/sync/sync_service.dart'; import 'package:uuid/uuid.dart'; @@ -47,6 +48,7 @@ class CliCommands { Future _addRepoTask(CliArgs args, String repo, String text) async { _report(await session.sync(push: true)); final (:title, :body) = splitNote(text); + if (args.newNote) return await _addSeparate(args, repo, title, body); final added = await addRepoTask( session.repository, repo: repo, @@ -63,6 +65,33 @@ class CliCommands { return 0; } + Future _addSeparate( + CliArgs args, + String repo, + String title, + String body, + ) async { + final added = await addSeparateTask( + session.repository, + repo: repo, + title: title, + body: body, + doneCheck: args.opts.doneCheck, + priority: args.priority, + by: args.opts.by, + ); + await _save(added.note); + if (!await _push('note')) return 1; + if (added.kind == SplitKind.merged) { + _err.writeln( + 'merged into the separate note with that title; ' + 'click Start on the board by hand', + ); + } + _out.writeln(added.note.id); + return 0; + } + /// Prints [listLine] for each note matching the filters in [args]. Future list(CliArgs args) async { _report(await session.sync(push: false)); diff --git a/lib/cli/cli_usage.dart b/lib/cli/cli_usage.dart index c136eda..7ea303a 100644 --- a/lib/cli/cli_usage.dart +++ b/lib/cli/cli_usage.dart @@ -13,6 +13,8 @@ A note with --repo is a repo task; one without is a plain idea. --repo repo task's repository, e.g. kuhyx/todo; with --list/--search: show only that repo's tasks --done-check shell command, run from the repo root, exit 0 = done + --new-note with --repo: own note, not the shared one (an exact + title in another separate note merges into it) --by claude|kuhy --list pull, then print live notes, one per line: 8-char id, status, priority, repo (- for none), title diff --git a/lib/data/repo_note.dart b/lib/data/repo_note.dart index c74c604..2271542 100644 --- a/lib/data/repo_note.dart +++ b/lib/data/repo_note.dart @@ -12,6 +12,18 @@ const Set kOpenStatuses = {Status.todo, Status.inProgress}; /// The repo note's title. String repoNoteTitle(String repo) => '${repo.split('/').last} todos'; +/// A `new_note` task's own note title: ` todos: `. +String separateNoteTitle(String repo, String title) => + '${repoNoteTitle(repo)}: ${title.trim()}'; + +/// Whether [note] is a `new_note` note, which the shared add path and the +/// one-time merge leave alone. +bool isSeparateNote(Note note) { + final repo = note.repo; + return repo != null && + splitNote(note.text).title.startsWith('${repoNoteTitle(repo)}: '); +} + /// Joins two checks with `&&`; either may be absent. String? composeDoneCheck(String? older, String? newer) { final a = (older ?? '').trim(); @@ -49,7 +61,8 @@ int byAge(Note a, Note b) { /// The note to save and whether it was appended. typedef RepoAdd = ({Note note, bool appended}); -/// Appends the task to [repo]'s oldest open note, or builds that note. +/// Appends the task to [repo]'s oldest open shared note, or builds that +/// note; separate (`new_note`) notes are never joined. Future<RepoAdd> addRepoTask( NoteRepository repository, { required String repo, @@ -60,9 +73,9 @@ Future<RepoAdd> addRepoTask( String? by, }) async { final section = repoSection(title, body); - final open = await repository.listNotes( + final open = (await repository.listNotes( filter: NoteFilter(statuses: kOpenStatuses, repo: repo), - ); + )).where((n) => !isSeparateNote(n)).toList(); if (open.isNotEmpty) { final existing = (open..sort(byAge)).first; return ( diff --git a/lib/data/repo_note_merge.dart b/lib/data/repo_note_merge.dart index 1d8c670..0980c8c 100644 --- a/lib/data/repo_note_merge.dart +++ b/lib/data/repo_note_merge.dart @@ -8,12 +8,15 @@ import 'package:todo/data/repo_note.dart'; typedef RepoMerge = ({Note kept, List<Note> removed}); /// A merge for every repo whose open notes are not yet one `<name> todos` -/// note; running it on its own result yields an empty plan. +/// note; running it on its own result yields an empty plan. Separate +/// (`new_note`) notes stay out of it. List<RepoMerge> planRepoMerges(Iterable<Note> notes) { final byRepo = <String, List<Note>>{}; for (final n in notes) { final repo = n.repo; - if (repo != null && kOpenStatuses.contains(n.status)) { + if (repo != null && + kOpenStatuses.contains(n.status) && + !isSeparateNote(n)) { byRepo.putIfAbsent(repo, () => []).add(n); } } diff --git a/lib/data/repo_note_split.dart b/lib/data/repo_note_split.dart new file mode 100644 index 0000000..57e00bb --- /dev/null +++ b/lib/data/repo_note_split.dart @@ -0,0 +1,112 @@ +/// `new_note`: a repo task in its own open note, `<name> todos: <title>`, +/// so it can close on its own done_check. A title that already heads an open +/// section of another separate note of the repo merges into that note +/// instead (it keeps its id); the shared `<name> todos` note never counts. +library; + +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/data/repo_note.dart'; +import 'package:uuid/uuid.dart'; + +/// How a separate add landed. +enum SplitKind { + /// A new separate note. + added, + + /// Appended to the separate note that already had the title. + merged, + + /// A new separate note because the duplicate's note was busy. + fellBack, +} + +/// The note to save, how it landed, and why it fell back. +typedef SplitAdd = ({Note note, SplitKind kind, String? why}); + +final _heading = RegExp(r'^##\s+(\[x\]\s+)?(.*)$'); + +/// The open `## title` headings of [text], outside code fences. +Iterable<String> openSections(String text) sync* { + var fenced = false; + for (final line in text.split('\n')) { + if (line.trimLeft().startsWith('```')) fenced = !fenced; + final m = fenced ? null : _heading.firstMatch(line); + if (m != null && m.group(1) == null) yield m.group(2)!.trim(); + } +} + +/// The separate open note of [repo] with an open section titled [title]. +Note? duplicateTarget(Iterable<Note> open, String repo, String title) { + final hits = + open + .where((n) => n.repo == repo && isSeparateNote(n)) + .where((n) => openSections(n.text).contains(title.trim())) + .toList() + ..sort(byAge); + return hits.isEmpty ? null : hits.first; +} + +/// Why a merged task must not be started, or null when it may be. +String? startBlock(String? doneCheck, String body) { + final check = (doneCheck ?? '').trim().toLowerCase(); + if (check.isEmpty || check == 'none') return 'done_check is none'; + final first = body + .split('\n') + .firstWhere((l) => l.trim().isNotEmpty, orElse: () => ''); + return first.trim().toLowerCase().startsWith('low priority') + ? 'marked low priority' + : null; +} + +/// Adds the task as its own note, or merges it into the duplicate's note +/// unless [busy] says a board session is working on it. +Future<SplitAdd> addSeparateTask( + NoteRepository repository, { + required String repo, + required String title, + String body = '', + String? doneCheck, + Priority priority = Priority.medium, + String? by, + Future<bool> Function(Note note)? busy, +}) async { + final section = repoSection(title, body); + final open = await repository.listNotes( + filter: NoteFilter(statuses: kOpenStatuses, repo: repo), + ); + final target = duplicateTarget(open, repo, title); + String? why; + if (target != null) { + if (busy == null || !await busy(target)) { + return ( + note: target.copyWith( + text: '${target.text}\n\n$section', + doneCheck: composeDoneCheck(target.doneCheck, doneCheck), + priority: maxPriority(target.priority, priority), + ), + kind: SplitKind.merged, + why: null, + ); + } + why = + 'not merged into ${target.id.substring(0, 8)}: ' + 'a board session is running on it'; + } + final now = DateTime.now(); + return ( + note: Note( + id: const Uuid().v4(), + text: '${separateNoteTitle(repo, title)}\n\n$section', + priority: priority, + status: Status.todo, + createdAt: now, + updatedAt: now, + repo: repo, + doneCheck: composeDoneCheck(null, doneCheck), + by: by, + ), + kind: why == null ? SplitKind.added : SplitKind.fellBack, + why: why, + ); +} diff --git a/lib/mcp/board_client.dart b/lib/mcp/board_client.dart new file mode 100644 index 0000000..0b952ee --- /dev/null +++ b/lib/mcp/board_client.dart @@ -0,0 +1,100 @@ +/// The todoImplementer board as `add_task new_note` sees it: whether a +/// note has a session running, and a Start click on a merged task. Start +/// goes through the board's own page (CSRF meta tag, same Origin), so the +/// board needs no new endpoint. Failures come back as reasons, never throws: +/// a board that is down must not lose the task that was just saved. +library; + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// Where the board listens (loopback only). +final Uri kBoardBase = Uri.parse('http://127.0.0.1:8786'); + +/// A Start attempt: the item started, or why not. +typedef StartResult = ({bool ok, String? reason, String? item}); + +final _csrf = RegExp(r'<meta\s+name="todoimpl-csrf"\s+content="([^"]+)"'); + +/// Talks to the board over [client] at [base]. +class BoardClient { + /// [client] is injectable so tests need no network. + BoardClient({http.Client? client, Uri? base}) + : _http = client ?? http.Client(), + base = base ?? kBoardBase; + + final http.Client _http; + + /// The board's origin; also the `Origin` header of a POST. + final Uri base; + + static const _timeout = Duration(seconds: 5); + + Future<List<Map<String, dynamic>>> _cards(String noteId) async { + final res = await _http.get(base.resolve('/api/board')).timeout(_timeout); + if (res.statusCode != 200) { + throw http.ClientException('GET /api/board ${res.statusCode}'); + } + final tasks = _json(res)['tasks']; + final prefix = '${noteId.substring(0, 8)}-'; + return [ + for (final t in (tasks as List).cast<Map<String, dynamic>>()) + if ((t['id']! as String).startsWith(prefix)) t, + ]; + } + + /// Whether any item of the note is in the board's Running column; false + /// when the board cannot be reached. + Future<bool> running(String noteId) async { + try { + return (await _cards(noteId)).any((c) => c['column'] == 'running'); + } on Object { + return false; + } + } + + /// Clicks Start on the note's last startable item titled [title]. + Future<StartResult> start(String noteId, String title) async { + try { + final card = (await _cards(noteId)).reversed + .where((c) => c['title'] == title && _startable(c)) + .firstOrNull; + if (card == null) return _no('no startable item'); + final page = await _http.get(base).timeout(_timeout); + final token = _csrf.firstMatch(page.body)?.group(1); + if (token == null) return _no('no CSRF token on the board page'); + final id = card['id']! as String; + final res = await _http + .post( + base.resolve('/api/task/$id/start'), + headers: {'Origin': base.origin, 'X-Todoimpl-Token': token}, + ) + .timeout(_timeout); + if (res.statusCode == 200) return (ok: true, reason: null, item: id); + return _no('board refused start: ${res.statusCode} ${_message(res)}'); + } on Object catch (e) { + return _no('board unreachable: $e'); + } + } + + static bool _startable(Map<String, dynamic> card) => (card['actions'] as List) + .cast<Map<String, dynamic>>() + .any((a) => a['id'] == 'start' && a['enabled'] == true); + + static String _message(http.Response res) { + try { + final error = _json(res)['error']; + return (error as Map<String, dynamic>)['message'] as String; + } on Object { + return res.body; + } + } + + // The board's JSON is UTF-8 whatever the Content-Type says. + static Map<String, dynamic> _json(http.Response res) => + jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>; + + static StartResult _no(String reason) => + (ok: false, reason: reason, item: null); +} diff --git a/lib/mcp/mcp_main.dart b/lib/mcp/mcp_main.dart index 4899874..b867670 100644 --- a/lib/mcp/mcp_main.dart +++ b/lib/mcp/mcp_main.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:todo/cli/cli_session.dart'; +import 'package:todo/mcp/board_client.dart'; import 'package:todo/mcp/task_service.dart'; import 'package:todo/mcp/todo_mcp_server.dart'; @@ -30,7 +31,7 @@ Future<Future<void> Function()?> startTodoMcp( ); await session.sync(push: true); final server = await TodoMcpServer.start( - tasks: TaskService(session), + tasks: TaskService(session, board: BoardClient()), tokens: tokens, port: int.tryParse(env['TODO_MCP_PORT'] ?? '') ?? kTodoMcpPort, ); diff --git a/lib/mcp/task_service.dart b/lib/mcp/task_service.dart index ca32401..fe965ac 100644 --- a/lib/mcp/task_service.dart +++ b/lib/mcp/task_service.dart @@ -4,13 +4,17 @@ import 'package:todo/cli/note_lookup.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; import 'package:todo/data/repo_note.dart'; +import 'package:todo/mcp/board_client.dart'; +import 'package:todo/mcp/task_split.dart'; /// The six task tools over one persistent sync node; calls run one at a time. class TaskService { /// Serves [_session]'s notes; every call pulls first, writes then push. - TaskService(this._session); + /// [board] is the todoImplementer board `new_note` checks and starts on. + TaskService(this._session, {this._board}); final CliSession _session; + final BoardClient? _board; Future<void> _tail = Future.value(); Future<String> _serial(Future<String> Function() body) { @@ -46,6 +50,18 @@ class TaskService { final repo = a.repo(required: true)!; final title = a.required('title'); await _pull(); + if (a.flag('new_note')) { + return await addSplit( + _session.repository, + _board, + _save, + repo: repo, + title: title, + body: a.optional('body') ?? '', + doneCheck: a.optional('done_check'), + priority: a.priority() ?? Priority.medium, + ); + } final added = await addRepoTask( _session.repository, repo: repo, @@ -139,6 +155,12 @@ final Map<String, Status> _statuses = { extension type _Args(Map<String, dynamic> _args) { bool has(String name) => _args.containsKey(name); + bool flag(String name) => switch (_args[name]) { + null || false => false, + true => true, + _ => throw FormatException('$name: must be a boolean'), + }; + String? optional(String name) { final value = _args[name]; if (value == null) return null; diff --git a/lib/mcp/task_split.dart b/lib/mcp/task_split.dart new file mode 100644 index 0000000..9824780 --- /dev/null +++ b/lib/mcp/task_split.dart @@ -0,0 +1,48 @@ +/// `add_task` with `new_note: true`: the separate note, the guard merge and +/// the board Start that follows a merge, as one reply text. +library; + +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/data/repo_note_split.dart'; +import 'package:todo/mcp/board_client.dart'; + +/// Saves [note] and returns `<verb> <id>` plus its list line. +typedef SaveNote = Future<String> Function(Note note, String verb); + +/// Adds a separate task, merging into a duplicate's note when it is idle, +/// and clicks Start on the board after a merge that may run. +Future<String> addSplit( + NoteRepository repository, + BoardClient? board, + SaveNote save, { + required String repo, + required String title, + required String body, + required String? doneCheck, + required Priority priority, +}) async { + final added = await addSeparateTask( + repository, + repo: repo, + title: title, + body: body, + doneCheck: doneCheck, + priority: priority, + by: 'claude', + busy: board == null ? null : (n) => board.running(n.id), + ); + if (added.kind != SplitKind.merged) { + final reply = await save(added.note, 'added'); + return added.why == null ? reply : '$reply\n${added.why}'; + } + final reply = await save(added.note, 'merged into'); + final block = startBlock(doneCheck, body); + if (block != null) return '$reply\nstart: skipped ($block)'; + final start = board == null + ? (ok: false, reason: 'no board configured', item: null) + : await board.start(added.note.id, title.trim()); + if (start.ok) return '$reply\nstart: ok ${start.item}'; + return '$reply\nstart: {ok: false, reason: "${start.reason}"}; ' + 'the merge is saved, click Start on the board by hand'; +} diff --git a/lib/mcp/todo_mcp_server.dart b/lib/mcp/todo_mcp_server.dart index a433c46..6101fa5 100644 --- a/lib/mcp/todo_mcp_server.dart +++ b/lib/mcp/todo_mcp_server.dart @@ -96,13 +96,19 @@ McpServer _server(TaskService tasks) { "Add a task to the repo's single open note (by: claude): appended as a " '"## title" section, or the note is created. Returns "added <full id>" ' 'or "appended to <full id>", then its list line. Use when you find ' - 'work you will not finish now.', + 'work you will not finish now. With new_note: true the task gets ' + 'its own note ("added"), or, when another separate note of the repo ' + 'already has that exact title, "merged into" that note, followed by ' + 'a "start:" line for the todoImplementer board.', { 'repo': repo, 'title': JsonSchema.string(description: 'One-line title.'), 'body': JsonSchema.string(description: 'Markdown: what/where/verify.'), 'done_check': doneCheck, 'priority': priority, + 'new_note': JsonSchema.boolean( + description: 'Own note instead of joining the shared one.', + ), }, ['repo', 'title'], tasks.add, diff --git a/test/cli/cli_new_note_test.dart b/test/cli/cli_new_note_test.dart new file mode 100644 index 0000000..9c2c508 --- /dev/null +++ b/test/cli/cli_new_note_test.dart @@ -0,0 +1,40 @@ +// `--repo --new-note`: the task gets its own note; an exact title in another +// separate note merges into it. The CLI never talks to the board. ASCII +// titles: FakeRtdb encodes its responses as Latin-1. +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/cli/cli_args.dart'; + +import 'cli_harness.dart'; + +void main() { + late CliHarness h; + setUp(() => h = CliHarness()); + tearDown(() => h.dispose()); + + Future<String> add(String text, [List<String> more = const []]) => + h.add(text, ['--repo', 'kuhyx/herosi', ...more]); + + test('C1: --new-note opens a second note; a duplicate merges', () async { + final shared = await add('Old\n\nx'); + final split = await add('Mlody\n\nwhat', ['--new-note']); + expect(split, isNot(shared)); + final again = await add('Mlody\n\nmore', ['--new-note']); + expect(again, split); + expect(h.err.toString(), contains('merged into')); + expect(h.err.toString(), contains('click Start on the board by hand')); + expect(await h.run(['--list', '--repo', 'kuhyx/herosi']), 0); + expect(h.out.toString().trim().split('\n'), hasLength(2)); + expect(await h.run(['--get', split]), 0); + expect(h.out.toString(), contains('herosi todos: Mlody')); + }); + + test('--new-note needs --repo and an add', () { + expect(() => CliArgs.parse(['--new-note']), throwsA(isA<UsageError>())); + expect( + () => CliArgs.parse(['--list', '--repo', 'a/b', '--new-note']), + throwsA(isA<UsageError>()), + ); + expect(CliArgs.parse(['--repo', 'a/b', '--new-note']).newNote, isTrue); + expect(CliArgs.parse(['--repo', 'a/b']).newNote, isFalse); + }); +} diff --git a/test/mcp/board_client_test.dart b/test/mcp/board_client_test.dart new file mode 100644 index 0000000..1c57c23 --- /dev/null +++ b/test/mcp/board_client_test.dart @@ -0,0 +1,130 @@ +// BoardClient: scrape the CSRF meta, find the startable item by title, +// POST start; every failure is a reason, never an exception. +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:todo/mcp/board_client.dart'; + +const _id = 'cccccccc-1111-2222-3333-444444444444'; + +Map<String, dynamic> _card( + String id, + String title, { + String column = 'backlog', + bool start = true, +}) => { + 'id': id, + 'title': title, + 'column': column, + 'actions': [ + {'id': 'start', 'enabled': start}, + ], +}; + +class _Board { + _Board(this.cards); + final List<Map<String, dynamic>> cards; + final posts = <http.Request>[]; + int startStatus = 200; + int boardStatus = 200; + String startBody = '{"ok":false,"error":{"message":"nope"}}'; + String page = '<meta name="todoimpl-csrf" content="tok-1">'; + + BoardClient client() => BoardClient( + client: MockClient((req) async { + if (req.method == 'POST') { + posts.add(req); + return http.Response(startBody, startStatus); + } + if (req.url.path == '/api/board') { + return http.Response.bytes( + utf8.encode(jsonEncode({'tasks': cards})), + boardStatus, + ); + } + return http.Response(page, 200); + }), + ); +} + +void main() { + test('C6: starts the last startable item with that title', () async { + final b = _Board([ + _card('cccccccc-0', 'Młody'), + _card('cccccccc-1', 'Other'), + _card('cccccccc-2', 'Młody'), + _card('dddddddd-0', 'Młody'), + ]); + final r = await b.client().start(_id, 'Młody'); + expect(r, (ok: true, reason: null, item: 'cccccccc-2')); + final post = b.posts.single; + expect( + post.url.toString(), + 'http://127.0.0.1:8786/api/task/cccccccc-2/start', + ); + expect(post.headers['X-Todoimpl-Token'], 'tok-1'); + expect(post.headers['Origin'], 'http://127.0.0.1:8786'); + expect(post.body, isEmpty); + }); + + test( + 'C7: no startable item, refusal and missing token are reasons', + () async { + final b = _Board([_card('cccccccc-0', 'Młody', start: false)]); + expect( + (await b.client().start(_id, 'Młody')).reason, + 'no startable item', + ); + final refused = _Board([_card('cccccccc-0', 'Młody')])..startStatus = 409; + final r = await refused.client().start(_id, 'Młody'); + expect(r.ok, isFalse); + expect(r.reason, 'board refused start: 409 nope'); + final blank = _Board([_card('cccccccc-0', 'Młody')])..page = '<html>'; + expect( + (await blank.client().start(_id, 'Młody')).reason, + 'no CSRF token on the board page', + ); + }, + ); + + test('C7: a board that is down gives a reason, not a throw', () async { + final down = BoardClient( + client: MockClient((_) async => throw http.ClientException('refused')), + ); + final r = await down.start(_id, 'Młody'); + expect(r.ok, isFalse); + expect(r.reason, startsWith('board unreachable')); + expect(await down.running(_id), isFalse); + }); + + test('C5: running is true only for a running item of that note', () async { + final b = _Board([ + _card('cccccccc-0', 'A'), + _card('dddddddd-0', 'A', column: 'running'), + ]); + expect(await b.client().running(_id), isFalse); + b.cards.add(_card('cccccccc-1', 'B', column: 'running')); + expect(await b.client().running(_id), isTrue); + }); + + test('a broken board and a non-JSON refusal are reasons too', () async { + final b = _Board([_card('cccccccc-0', 'Młody')])..boardStatus = 500; + expect( + (await b.client().start(_id, 'Młody')).reason, + allOf(startsWith('board unreachable'), contains('GET /api/board 500')), + ); + final raw = _Board([_card('cccccccc-0', 'Młody')]) + ..startStatus = 502 + ..startBody = 'Bad Gateway'; + expect( + (await raw.client().start(_id, 'Młody')).reason, + 'board refused start: 502 Bad Gateway', + ); + }); + + test('the default client targets the loopback board', () { + expect(BoardClient().base.toString(), 'http://127.0.0.1:8786'); + }); +} diff --git a/test/mcp/mcp_harness.dart b/test/mcp/mcp_harness.dart index 93b3514..f779477 100644 --- a/test/mcp/mcp_harness.dart +++ b/test/mcp/mcp_harness.dart @@ -10,6 +10,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:mcp_dart/mcp_dart.dart'; import 'package:todo/cli/cli_session.dart'; +import 'package:todo/mcp/board_client.dart'; import 'package:todo/mcp/task_service.dart'; import 'package:todo/mcp/todo_mcp_server.dart'; import 'package:todo/mcp/token_file.dart'; @@ -17,6 +18,10 @@ import 'package:todo/mcp/token_file.dart'; import '../cli/fake_rtdb.dart'; class McpHarness { + /// [board] is the fake board `new_note` talks to; none by default. + McpHarness({this.board}); + + final BoardClient? board; static const token = 'test-token-for-pc'; final tmp = Directory.systemTemp.createTempSync('mcp_harness'); @@ -34,7 +39,7 @@ class McpHarness { httpClient: db.client, ); server = await TodoMcpServer.start( - tasks: TaskService(session), + tasks: TaskService(session, board: board), tokens: tokens, port: 0, ); diff --git a/test/mcp/mcp_new_note_test.dart b/test/mcp/mcp_new_note_test.dart new file mode 100644 index 0000000..d2a1d1c --- /dev/null +++ b/test/mcp/mcp_new_note_test.dart @@ -0,0 +1,128 @@ +// add_task new_note over MCP: second note, guard merge, Start on the board. +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mcp_dart/mcp_dart.dart'; +import 'package:todo/mcp/board_client.dart'; + +import 'mcp_harness.dart'; + +const _repo = 'kuhyx/herosi'; + +void main() { + late McpHarness h; + late McpClient c; + var cards = <Map<String, dynamic>>[]; + var up = true; + final posts = <String>[]; + final board = BoardClient( + client: MockClient((req) async { + if (!up) throw http.ClientException('connection refused'); + if (req.method == 'POST') { + posts.add(req.url.path); + return http.Response('{"ok":true}', 200); + } + if (req.url.path == '/api/board') { + return http.Response.bytes( + utf8.encode(jsonEncode({'tasks': cards})), + 200, + ); + } + return http.Response('<meta name="todoimpl-csrf" content="t">', 200); + }), + ); + List<Map<String, dynamic>> cardsOf(String id, String column) => [ + { + 'id': '${id.substring(0, 8)}-0', + 'title': 'Młody', + 'column': column, + 'actions': [ + {'id': 'start', 'enabled': true}, + ], + }, + ]; + + setUp(() async { + cards = []; + up = true; + posts.clear(); + h = McpHarness(board: board); + await h.start(); + c = await h.connect(); + }); + tearDown(() => h.dispose()); + + Future<String> add(Map<String, dynamic> extra) => h.call(c, 'add_task', { + 'repo': _repo, + 'title': 'Młody', + 'body': 'what', + 'new_note': true, + ...extra, + }); + + test('C1/C2: new_note opens a second note, a plain add does not', () async { + final shared = idIn( + await h.call(c, 'add_task', {'repo': _repo, 'title': 'Old'}), + ); + final before = await h.call(c, 'get_task', {'id': shared}); + final second = await add({}); + expect(second, startsWith('added ')); + expect(idIn(second), isNot(shared)); + expect(await h.call(c, 'get_task', {'id': shared}), before); + final listed = await h.call(c, 'list_tasks', {'repo': _repo}); + expect(listed.trim().split('\n'), hasLength(2)); + final plain = await h.call(c, 'add_task', {'repo': _repo, 'title': 'C'}); + expect(idIn(plain), shared); + expect(posts, isEmpty); + }); + + test('C3/C6: a duplicate merges and Start is POSTed', () async { + final x = idIn(await add({'done_check': 'true'})); + cards = cardsOf(x, 'backlog'); + final r = await add({'done_check': 'true'}); + expect(r, startsWith('merged into $x')); + expect(r, contains('start: ok ${x.substring(0, 8)}-0')); + expect(posts, ['/api/task/${x.substring(0, 8)}-0/start']); + final listed = await h.call(c, 'list_tasks', {'repo': _repo}); + expect(listed.trim().split('\n'), hasLength(1)); + }); + + test('C6: done_check none or low priority merges without Start', () async { + final x = idIn(await add({})); + cards = cardsOf(x, 'backlog'); + final none = await add({}); + expect(none, startsWith('merged into $x')); + expect(none, contains('start: skipped (done_check is none)')); + final low = await add({'done_check': 'true', 'body': 'low priority\nx'}); + expect(low, contains('start: skipped (marked low priority)')); + expect(posts, isEmpty); + }); + + test('C5: a running target is left alone', () async { + final x = idIn(await add({'done_check': 'true'})); + final before = await h.call(c, 'get_task', {'id': x}); + cards = cardsOf(x, 'running'); + final r = await add({'done_check': 'true'}); + expect(r, startsWith('added ')); + expect(idIn(r), isNot(x)); + expect(r, contains('not merged into ${x.substring(0, 8)}')); + expect(await h.call(c, 'get_task', {'id': x}), before); + }); + + test('C7: board down keeps the merge and says click Start', () async { + final x = idIn(await add({'done_check': 'true'})); + up = false; + final r = await add({'done_check': 'true'}); + expect(r, startsWith('merged into $x')); + expect(r, contains('start: {ok: false, reason: "board unreachable')); + expect(r, contains('click Start on the board by hand')); + }); + + test('the add_task schema offers new_note', () async { + final tools = (await c.listTools()).tools; + final add = tools.singleWhere((t) => t.name == 'add_task'); + expect(jsonEncode(add.inputSchema.toJson()), contains('new_note')); + }); +} diff --git a/test/repo_note_split_test.dart b/test/repo_note_split_test.dart new file mode 100644 index 0000000..0701958 --- /dev/null +++ b/test/repo_note_split_test.dart @@ -0,0 +1,140 @@ +// new_note: a second open note per repo, the duplicate guard that merges +// into it, and the shared add path that never joins such a note. +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/data/repo_note.dart'; +import 'package:todo/data/repo_note_merge.dart'; +import 'package:todo/data/repo_note_split.dart'; + +const _repo = 'kuhyx/herosi'; + +Note _n(String id, String text, {int day = 1, String repo = _repo}) => Note( + id: id, + text: text, + priority: Priority.medium, + status: Status.todo, + createdAt: DateTime.utc(2026, 10, day), + updatedAt: DateTime.utc(2026, 10, day), + repo: repo, + doneCheck: 'old', +); + +Future<Note> _get(NoteRepository r, String id) async => + (await r.listNotes()).singleWhere((n) => n.id == id); + +void main() { + late NoteRepository repo; + setUp(() async => repo = await NoteRepository.openInMemory()); + tearDown(() => repo.close()); + + Future<SplitAdd> add(String title, {Future<bool> Function(Note)? busy}) => + addSeparateTask( + repo, + repo: _repo, + title: title, + body: 'what', + doneCheck: 'new', + priority: Priority.high, + by: 'claude', + busy: busy, + ); + + final shared = _n('aaaaaaaa-shared', 'herosi todos\n\n## Mędrzec\nlisted'); + + test('C1: builds a second open note, the shared one untouched', () async { + await repo.upsert(shared); + final r = await add('Mędrzec'); + expect(r.kind, SplitKind.added); + expect(r.note.id, isNot(shared.id)); + expect(r.note.text, 'herosi todos: Mędrzec\n\n## Mędrzec\nwhat'); + expect(r.note.doneCheck, 'new'); + expect(isSeparateNote(r.note), isTrue); + expect(isSeparateNote(shared), isFalse); + expect((await _get(repo, shared.id)).text, shared.text); + }); + + test('C2: a plain add joins the shared note, never a separate one', () async { + final split = _n('00000000-split', 'herosi todos: Old\n\n## Old\nx'); + await repo.upsert(split); + await repo.upsert(_n('bbbbbbbb-shared', shared.text, day: 2)); + final r = await addRepoTask(repo, repo: _repo, title: 'B'); + expect(r.appended, isTrue); + expect(r.note.id, 'bbbbbbbb-shared'); + await repo.upsert( + _n('bbbbbbbb-shared', 'x', day: 2).copyWith(status: Status.done), + ); + final fresh = await addRepoTask(repo, repo: _repo, title: 'C'); + expect(fresh.appended, isFalse); + expect(fresh.note.text, startsWith('herosi todos\n')); + }); + + test('C3: an exact title in another separate note merges into it', () async { + final x = _n('cccccccc-x', 'herosi todos: Młody\n\n## Młody\nfirst'); + await repo.upsert(x); + final r = await add('Młody'); + expect(r.kind, SplitKind.merged); + expect(r.note.id, x.id); + expect(r.note.text, '${x.text}\n\n## Młody\nwhat'); + expect(r.note.doneCheck, '( old ) && ( new )'); + expect(r.note.priority, Priority.high); + }); + + test('C4: shared-note, other-repo, done or near hits do not merge', () async { + await repo.upsert(shared); + await repo.upsert( + _n('dddddddd-o', 'x todos: Hekser\n\n## Hekser\n', repo: 'kuhyx/x'), + ); + await repo.upsert(_n('eeeeeeee-d', 'herosi todos: H\n\n## [x] Hekser\n')); + await repo.upsert(_n('ffffffff-c', 'herosi todos: H\n\n## hekser \n')); + await repo.upsert( + _n('99999999-f', 'herosi todos: H\n\n```\n## Hekser\n```'), + ); + expect((await add('Hekser')).kind, SplitKind.added); + expect((await add('Mędrzec')).kind, SplitKind.added); + }); + + test( + 'C5: a running target is not edited; a separate note says why', + () async { + final x = _n('cccccccc-x', 'herosi todos: Młody\n\n## Młody\nfirst'); + await repo.upsert(x); + final seen = <String>[]; + final r = await add( + 'Młody', + busy: (n) async { + seen.add(n.id); + return true; + }, + ); + expect(seen, [x.id]); + expect(r.kind, SplitKind.fellBack); + expect(r.note.id, isNot(x.id)); + expect(r.why, contains('cccccccc')); + expect((await _get(repo, x.id)).text, x.text); + }, + ); + + test('C10: the one-time merge plans nothing for separate notes', () { + final notes = [ + _n('a', 'herosi todos\n\n## One\nx', day: 1), + _n('b', 'herosi todos: Two\n\n## Two\ny', day: 2), + _n('c', 'herosi todos: Three\n\n## Three\nz', day: 3), + ]; + expect(planRepoMerges(notes), isEmpty); + final legacy = planRepoMerges([...notes, _n('d', 'Legacy\n\nl', day: 4)]); + expect(legacy.single.kept.id, 'a'); + expect(legacy.single.removed.map((n) => n.id), ['d']); + }); + + group('startBlock', () { + test('a real check and a normal body may start', () { + expect(startBlock('test -f x', 'what'), isNull); + }); + test('C6: none or low priority never starts', () { + expect(startBlock(null, 'what'), 'done_check is none'); + expect(startBlock(' None ', 'what'), 'done_check is none'); + expect(startBlock('x', '\nLow priority\nrest'), 'marked low priority'); + }); + }); +}