From 0088d6e0e08e46253cccb2d6acc6eb7fad411101 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 22:57:27 +0200 Subject: [PATCH] feat(worker): MCP tools for child-task CRUD --- .../Planning/PlanningMcpService.cs | 85 ++++++++++++++ .../Planning/PlanningMcpServiceTests.cs | 107 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/ClaudeDo.Worker/Planning/PlanningMcpService.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs new file mode 100644 index 0000000..2bb1a34 --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs @@ -0,0 +1,85 @@ +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Planning; + +public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status, IReadOnlyList Tags); +public sealed record CreatedChildDto(string TaskId, string Status); + +public sealed class PlanningMcpService +{ + private readonly TaskRepository _tasks; + + public PlanningMcpService(TaskRepository tasks) => _tasks = tasks; + + public async Task CreateChildTask( + PlanningMcpContext ctx, + string title, + string? description, + IReadOnlyList? tags, + string? commitType, + CancellationToken cancellationToken) + { + var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken); + return new CreatedChildDto(child.Id, "Draft"); + } + + public async Task> ListChildTasks( + PlanningMcpContext ctx, + CancellationToken cancellationToken) + { + var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken); + var list = new List(children.Count); + foreach (var c in children) + { + var tagList = await _tasks.GetTagsAsync(c.Id, cancellationToken); + list.Add(new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString(), tagList.Select(t => t.Name).ToList())); + } + return list; + } + + public async Task UpdateChildTask( + PlanningMcpContext ctx, + string taskId, + string? title, + string? description, + IReadOnlyList? tags, + string? commitType, + CancellationToken cancellationToken) + { + var child = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (child.ParentTaskId != ctx.ParentTaskId) + throw new InvalidOperationException("Task is not a child of this planning session."); + if (child.Status != TaskStatus.Draft) + throw new InvalidOperationException("Cannot modify a finalized task."); + + if (title is not null) child.Title = title; + if (description is not null) child.Description = description; + if (commitType is not null) child.CommitType = commitType; + await _tasks.UpdateAsync(child, cancellationToken); + + // Tag handling omitted for v1 simplicity — tags set at create time. + // If Claude asks to update tags, it can delete and re-create. + + var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; + var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken); + return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList()); + } + + public async Task DeleteChildTask( + PlanningMcpContext ctx, + string taskId, + CancellationToken cancellationToken) + { + var child = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (child.ParentTaskId != ctx.ParentTaskId) + throw new InvalidOperationException("Task is not a child of this planning session."); + if (child.Status != TaskStatus.Draft) + throw new InvalidOperationException("Cannot delete a finalized task."); + + await _tasks.DeleteAsync(taskId, cancellationToken); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs new file mode 100644 index 0000000..fdc0505 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs @@ -0,0 +1,107 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Planning; + +public sealed class PlanningMcpServiceTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly PlanningMcpService _sut; + + public PlanningMcpServiceTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _sut = new PlanningMcpService(_tasks); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private async Task SeedPlanningParentAsync() + { + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + var parent = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "p", + Status = TaskStatus.Manual, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(parent); + await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); + return (await _tasks.GetByIdAsync(parent.Id))!; + } + + private static PlanningMcpContext Ctx(string parentId) => new() { ParentTaskId = parentId }; + + [Fact] + public async Task CreateChildTask_CreatesDraft() + { + var parent = await SeedPlanningParentAsync(); + + var result = await _sut.CreateChildTask(Ctx(parent.Id), "My child", "desc", null, null, CancellationToken.None); + + Assert.Equal("Draft", result.Status); + var child = await _tasks.GetByIdAsync(result.TaskId); + Assert.Equal("My child", child!.Title); + Assert.Equal(TaskStatus.Draft, child.Status); + } + + [Fact] + public async Task ListChildTasks_ReturnsOnlyThisParentsChildren() + { + var parent = await SeedPlanningParentAsync(); + var other = await SeedPlanningParentAsync(); + + await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null); + await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null); + + var list = await _sut.ListChildTasks(Ctx(parent.Id), CancellationToken.None); + Assert.Single(list); + Assert.Equal("mine", list[0].Title); + } + + [Fact] + public async Task UpdateChildTask_NotAChild_Throws() + { + var parent = await SeedPlanningParentAsync(); + var other = await SeedPlanningParentAsync(); + var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null, null); + + await Assert.ThrowsAsync(() => + _sut.UpdateChildTask(Ctx(parent.Id), otherChild.Id, "new", null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task UpdateChildTask_NotDraft_Throws() + { + var parent = await SeedPlanningParentAsync(); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false); + + await Assert.ThrowsAsync(() => + _sut.UpdateChildTask(Ctx(parent.Id), c.Id, "new", null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task DeleteChildTask_RemovesDraft() + { + var parent = await SeedPlanningParentAsync(); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + + await _sut.DeleteChildTask(Ctx(parent.Id), c.Id, CancellationToken.None); + + Assert.Null(await _tasks.GetByIdAsync(c.Id)); + } +}