feat(findings): resolve the target store from task or list

This commit is contained in:
mika kuns
2026-08-10 10:13:29 +02:00
parent 389892d277
commit 9a09dd6186
3 changed files with 155 additions and 0 deletions
@@ -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);
}