diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 9161ee9..48ca97a 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -194,6 +194,27 @@ public sealed class TaskRepository } } + public async Task SetTagsAsync(string taskId, IReadOnlyList tagNames, CancellationToken ct = default) + { + var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct); + if (task is null) return; + + task.Tags.Clear(); + + foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct); + if (tag is null) + { + tag = new TagEntity { Name = name }; + _context.Tags.Add(tag); + } + task.Tags.Add(tag); + } + + await _context.SaveChangesAsync(ct); + } + public async Task> GetTagsAsync(string taskId, CancellationToken ct = default) { return await _context.Tasks @@ -276,6 +297,41 @@ public sealed class TaskRepository return child; } + public async Task UpdateChildAsync( + string taskId, + string? title, + string? description, + string? commitType, + IReadOnlyList? tagNames, + TaskStatus? status, + CancellationToken ct = default) + { + var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + + if (title is not null) task.Title = title; + if (description is not null) task.Description = description; + if (commitType is not null) task.CommitType = commitType; + if (status.HasValue) task.Status = status.Value; + + if (tagNames is not null) + { + task.Tags.Clear(); + foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct); + if (tag is null) + { + tag = new TagEntity { Name = name }; + _context.Tags.Add(tag); + } + task.Tags.Add(tag); + } + } + + await _context.SaveChangesAsync(ct); + } + public async Task UpdatePlanningTaskAsync( string taskId, string? title, diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs index 690cdb0..017f0d6 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs @@ -340,4 +340,60 @@ public sealed class TaskRepositoryTests : IDisposable Assert.Contains("code", names); Assert.Contains("manual", names); } + + [Fact] + public async Task SetTagsAsync_AttachesNewTagsAndCreatesMissingRows() + { + var listId = await CreateListAsync("L"); + var task = MakeTask(listId); + await _tasks.AddAsync(task); + + await _tasks.SetTagsAsync(task.Id, new[] { "agent", "novel-tag" }); + + var tags = await _tasks.GetTagsAsync(task.Id); + Assert.Contains(tags, t => t.Name == "agent"); + Assert.Contains(tags, t => t.Name == "novel-tag"); + Assert.Equal(2, tags.Count); + } + + [Fact] + public async Task SetTagsAsync_ReplacesExistingTagSet() + { + var listId = await CreateListAsync("L"); + var task = MakeTask(listId); + await _tasks.AddAsync(task); + await _tasks.SetTagsAsync(task.Id, new[] { "agent" }); + + await _tasks.SetTagsAsync(task.Id, new[] { "manual" }); + + var tags = await _tasks.GetTagsAsync(task.Id); + Assert.Single(tags); + Assert.Equal("manual", tags[0].Name); + } + + [Fact] + public async Task SetTagsAsync_DeduplicatesCaseInsensitively() + { + var listId = await CreateListAsync("L"); + var task = MakeTask(listId); + await _tasks.AddAsync(task); + + await _tasks.SetTagsAsync(task.Id, new[] { "agent", "AGENT", "Agent" }); + + var tags = await _tasks.GetTagsAsync(task.Id); + Assert.Single(tags); + } + + [Fact] + public async Task SetTagsAsync_EmptyListClearsAllTags() + { + var listId = await CreateListAsync("L"); + var task = MakeTask(listId); + await _tasks.AddAsync(task); + await _tasks.SetTagsAsync(task.Id, new[] { "agent" }); + + await _tasks.SetTagsAsync(task.Id, Array.Empty()); + + Assert.Empty(await _tasks.GetTagsAsync(task.Id)); + } }