feat(findings): resolve the target store from task or list
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Findings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<FindingsTarget> 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<FindingsTarget> 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<ListEntity> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ClaudeDo.Worker.Findings;
|
||||||
|
|
||||||
|
/// <summary>The main checkout a finding belongs to, plus whether its store is committed with the repo.</summary>
|
||||||
|
public sealed record FindingsTarget(string ListId, string ListName, string WorkingDir, bool Tracked);
|
||||||
|
|
||||||
|
public interface IFindingsStoreLocator
|
||||||
|
{
|
||||||
|
Task<FindingsTarget> ResolveForTaskAsync(string taskId, CancellationToken ct);
|
||||||
|
Task<FindingsTarget> ResolveForListAsync(string listIdOrName, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -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<InvalidOperationException>(
|
||||||
|
() => 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<InvalidOperationException>(
|
||||||
|
() => locator.ResolveForListAsync("Alpha", CancellationToken.None));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user