mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
9a8e7aefd2
commit
75248e9cde
@ -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<String> flags, CliMode mode) {
|
||||
|
||||
@ -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<int> _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<int> _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<int> list(CliArgs args) async {
|
||||
_report(await session.sync(push: false));
|
||||
|
||||
@ -13,6 +13,8 @@ A note with --repo is a repo task; one without is a plain idea.
|
||||
--repo <owner/name> repo task's repository, e.g. kuhyx/todo;
|
||||
with --list/--search: show only that repo's tasks
|
||||
--done-check <cmd> 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 <who> claude|kuhy
|
||||
--list pull, then print live notes, one per line:
|
||||
8-char id, status, priority, repo (- for none), title
|
||||
|
||||
@ -12,6 +12,18 @@ const Set<Status> 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: `<name> todos: <title>`.
|
||||
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 (
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
112
lib/data/repo_note_split.dart
Normal file
112
lib/data/repo_note_split.dart
Normal file
@ -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,
|
||||
);
|
||||
}
|
||||
100
lib/mcp/board_client.dart
Normal file
100
lib/mcp/board_client.dart
Normal file
@ -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);
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
48
lib/mcp/task_split.dart
Normal file
48
lib/mcp/task_split.dart
Normal file
@ -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';
|
||||
}
|
||||
@ -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,
|
||||
|
||||
40
test/cli/cli_new_note_test.dart
Normal file
40
test/cli/cli_new_note_test.dart
Normal file
@ -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);
|
||||
});
|
||||
}
|
||||
130
test/mcp/board_client_test.dart
Normal file
130
test/mcp/board_client_test.dart
Normal file
@ -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');
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
|
||||
128
test/mcp/mcp_new_note_test.dart
Normal file
128
test/mcp/mcp_new_note_test.dart
Normal file
@ -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'));
|
||||
});
|
||||
}
|
||||
140
test/repo_note_split_test.dart
Normal file
140
test/repo_note_split_test.dart
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user