From 9a09dd6186bdf994c98556f91edfdbeda8f88b65 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 10 Aug 2026 10:13:29 +0200 Subject: [PATCH] feat(findings): resolve the target store from task or list --- .../Findings/FindingsStoreLocator.cs | 62 ++++++++++++++ .../Interfaces/IFindingsStoreLocator.cs | 10 +++ .../Findings/FindingsStoreLocatorTests.cs | 83 +++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/ClaudeDo.Worker/Findings/FindingsStoreLocator.cs create mode 100644 src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStoreLocator.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreLocatorTests.cs diff --git a/src/ClaudeDo.Worker/Findings/FindingsStoreLocator.cs b/src/ClaudeDo.Worker/Findings/FindingsStoreLocator.cs new file mode 100644 index 00000000..2effbe0c --- /dev/null +++ b/src/ClaudeDo.Worker/Findings/FindingsStoreLocator.cs @@ -0,0 +1,62 @@ +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; + +namespace ClaudeDo.Worker.Findings; + +/// +/// Maps an MCP call to the project whose findings store it targets. Always resolves to the list's +/// WorkingDir — the main checkout — even when the caller runs inside a worktree, because concurrent +/// writes into worktree copies would produce INDEX.md merge conflicts. +/// +public sealed class FindingsStoreLocator : IFindingsStoreLocator +{ + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + + public FindingsStoreLocator(TaskRepository tasks, ListRepository lists) + { + _tasks = tasks; + _lists = lists; + } + + public async Task ResolveForTaskAsync(string taskId, CancellationToken ct) + { + var task = await _tasks.GetByIdAsync(taskId, ct) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + var list = await _lists.GetByIdAsync(task.ListId, ct) + ?? throw new InvalidOperationException($"List {task.ListId} not found."); + return ToTarget(list); + } + + public async Task ResolveForListAsync(string listIdOrName, CancellationToken ct) + { + var all = await _lists.GetAllAsync(ct); + + if (!string.IsNullOrWhiteSpace(listIdOrName)) + { + var match = all.FirstOrDefault(l => l.Id == listIdOrName) + ?? all.FirstOrDefault(l => string.Equals(l.Name, listIdOrName, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException( + $"No list matches '{listIdOrName}'. Known lists: {Names(all)}."); + return ToTarget(match); + } + + var withDir = all.Where(l => !string.IsNullOrWhiteSpace(l.WorkingDir)).ToList(); + return withDir.Count switch + { + 1 => ToTarget(withDir[0]), + 0 => throw new InvalidOperationException("No list has a working directory, so there is nowhere to save a finding."), + _ => throw new InvalidOperationException( + $"Several lists have a working directory — pass 'list' to pick one: {Names(withDir)}."), + }; + } + + private static string Names(IEnumerable lists) => string.Join(", ", lists.Select(l => l.Name)); + + private static FindingsTarget ToTarget(ListEntity list) + { + if (string.IsNullOrWhiteSpace(list.WorkingDir)) + throw new InvalidOperationException($"List '{list.Name}' has no working directory, so it has no findings store."); + return new FindingsTarget(list.Id, list.Name, list.WorkingDir, list.FindingsTracked); + } +} diff --git a/src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStoreLocator.cs b/src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStoreLocator.cs new file mode 100644 index 00000000..ff075921 --- /dev/null +++ b/src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStoreLocator.cs @@ -0,0 +1,10 @@ +namespace ClaudeDo.Worker.Findings; + +/// The main checkout a finding belongs to, plus whether its store is committed with the repo. +public sealed record FindingsTarget(string ListId, string ListName, string WorkingDir, bool Tracked); + +public interface IFindingsStoreLocator +{ + Task ResolveForTaskAsync(string taskId, CancellationToken ct); + Task ResolveForListAsync(string listIdOrName, CancellationToken ct); +} diff --git a/tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreLocatorTests.cs b/tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreLocatorTests.cs new file mode 100644 index 00000000..221a38c9 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreLocatorTests.cs @@ -0,0 +1,83 @@ +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Findings; +using ClaudeDo.Worker.Tests.Infrastructure; + +namespace ClaudeDo.Worker.Tests.Findings; + +public sealed class FindingsStoreLocatorTests : IDisposable +{ + private readonly DbFixture _fx = new(); + + public void Dispose() => _fx.Dispose(); + + [Fact] + public async Task ResolveForTask_UsesTheTasksListWorkingDir() + { + using var db = _fx.CreateContext(); + db.Lists.Add(new ListEntity { Id = "l1", Name = "A", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo-a", FindingsTracked = true }); + db.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "x", CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + var locator = new FindingsStoreLocator(new TaskRepository(db), new ListRepository(db)); + + var target = await locator.ResolveForTaskAsync("t1", CancellationToken.None); + + Assert.Equal(@"C:\repo-a", target.WorkingDir); + Assert.True(target.Tracked); + } + + [Fact] + public async Task ResolveForList_MatchesByIdOrName() + { + using var db = _fx.CreateContext(); + db.Lists.Add(new ListEntity { Id = "l1", Name = "Alpha", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo-a" }); + db.Lists.Add(new ListEntity { Id = "l2", Name = "Beta", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo-b" }); + await db.SaveChangesAsync(); + var locator = new FindingsStoreLocator(new TaskRepository(db), new ListRepository(db)); + + Assert.Equal(@"C:\repo-b", (await locator.ResolveForListAsync("l2", CancellationToken.None)).WorkingDir); + Assert.Equal(@"C:\repo-a", (await locator.ResolveForListAsync("Alpha", CancellationToken.None)).WorkingDir); + } + + [Fact] + public async Task ResolveForList_WithoutArgument_UsesTheOnlyListWithAWorkingDir() + { + using var db = _fx.CreateContext(); + db.Lists.Add(new ListEntity { Id = "l1", Name = "Alpha", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo-a" }); + db.Lists.Add(new ListEntity { Id = "l2", Name = "NoDir", CreatedAt = DateTime.UtcNow, WorkingDir = null }); + await db.SaveChangesAsync(); + var locator = new FindingsStoreLocator(new TaskRepository(db), new ListRepository(db)); + + var target = await locator.ResolveForListAsync("", CancellationToken.None); + + Assert.Equal(@"C:\repo-a", target.WorkingDir); + } + + [Fact] + public async Task ResolveForList_WithoutArgument_ThrowsAndNamesCandidatesWhenAmbiguous() + { + using var db = _fx.CreateContext(); + db.Lists.Add(new ListEntity { Id = "l1", Name = "Alpha", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo-a" }); + db.Lists.Add(new ListEntity { Id = "l2", Name = "Beta", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo-b" }); + await db.SaveChangesAsync(); + var locator = new FindingsStoreLocator(new TaskRepository(db), new ListRepository(db)); + + var ex = await Assert.ThrowsAsync( + () => locator.ResolveForListAsync("", CancellationToken.None)); + + Assert.Contains("Alpha", ex.Message); + Assert.Contains("Beta", ex.Message); + } + + [Fact] + public async Task ResolveForList_ThrowsWhenTheListHasNoWorkingDir() + { + using var db = _fx.CreateContext(); + db.Lists.Add(new ListEntity { Id = "l1", Name = "Alpha", CreatedAt = DateTime.UtcNow, WorkingDir = null }); + await db.SaveChangesAsync(); + var locator = new FindingsStoreLocator(new TaskRepository(db), new ListRepository(db)); + + await Assert.ThrowsAsync( + () => locator.ResolveForListAsync("Alpha", CancellationToken.None)); + } +}