feat(daily-prep): add set_my_day MCP tool with cap-guard

This commit is contained in:
mika kuns
2026-06-03 16:19:36 +02:00
parent 0bb809445e
commit fd7f8ac78f
2 changed files with 87 additions and 2 deletions

View File

@@ -30,7 +30,9 @@ public sealed record TaskDto(
string? CreatedBy, string? CreatedBy,
DateTime CreatedAt, DateTime CreatedAt,
DateTime? StartedAt, DateTime? StartedAt,
DateTime? FinishedAt); DateTime? FinishedAt,
bool IsMyDay,
int SortOrder);
public sealed record WorktreeInfoDto( public sealed record WorktreeInfoDto(
string Path, string Branch, string HeadCommit, string BaseCommit, string Path, string Branch, string HeadCommit, string BaseCommit,
@@ -529,6 +531,41 @@ public sealed class ExternalMcpService
return new DailyPrepDataDto(maxTasks, candidates, currentMyDay); return new DailyPrepDataDto(maxTasks, candidates, currentMyDay);
} }
[McpServerTool, Description(
"Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " +
"(use consecutive sortOrder values to keep related tasks together). " +
"Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " +
"clearing (isMyDay=false) is always allowed.")]
public async Task<TaskDto> SetMyDay(
string taskId,
bool isMyDay,
int? sortOrder,
CancellationToken cancellationToken)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (isMyDay && !task.IsMyDay)
{
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
var max = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
var openMyDay = await ctx.Tasks.CountAsync(
t => t.IsMyDay && t.Status == TaskStatus.Idle, cancellationToken);
if (openMyDay >= max)
throw new InvalidOperationException(
$"MyDay limit {max} reached. Clear a task before adding another.");
}
task.IsMyDay = isMyDay;
if (sortOrder is not null) task.SortOrder = sortOrder.Value;
await ctx.SaveChangesAsync(cancellationToken);
await _broadcaster.TaskUpdated(taskId);
return ToDto(task);
}
private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new( private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new(
t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description, t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description,
t.IsStarred, t.ScheduledFor, t.CreatedAt); t.IsStarred, t.ScheduledFor, t.CreatedAt);
@@ -618,7 +655,9 @@ public sealed class ExternalMcpService
t.CreatedBy, t.CreatedBy,
t.CreatedAt, t.CreatedAt,
t.StartedAt, t.StartedAt,
t.FinishedAt); t.FinishedAt,
t.IsMyDay,
t.SortOrder);
} }
internal static class DailyPrepFilter internal static class DailyPrepFilter

View File

@@ -254,6 +254,15 @@ public sealed class ExternalMcpServiceTests : IDisposable
sut.DeleteTask("does-not-exist", CancellationToken.None)); sut.DeleteTask("does-not-exist", CancellationToken.None));
} }
private ExternalMcpService NewService() => BuildSut(CreateQueue());
private async Task<string> SeedIdleTask(string title = "t")
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, title, TaskStatus.Idle);
return task.Id;
}
private async Task SeedAppSettingsAsync(string? reportExcludedPaths, int dailyPrepMaxTasks = 5) private async Task SeedAppSettingsAsync(string? reportExcludedPaths, int dailyPrepMaxTasks = 5)
{ {
var settings = new AppSettingsEntity var settings = new AppSettingsEntity
@@ -366,4 +375,41 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Equal("myday-task", result.CurrentMyDay[0].Id); Assert.Equal("myday-task", result.CurrentMyDay[0].Id);
Assert.Equal(5, result.MaxTasks); Assert.Equal(5, result.MaxTasks);
} }
[Fact]
public async Task SetMyDay_sets_flag_and_sort_order()
{
var svc = NewService();
var id = await SeedIdleTask("My task");
var dto = await svc.SetMyDay(id, isMyDay: true, sortOrder: 3, CancellationToken.None);
Assert.True(dto.IsMyDay);
Assert.Equal(3, dto.SortOrder);
}
[Fact]
public async Task SetMyDay_rejects_when_cap_reached()
{
await SeedAppSettingsAsync(null, dailyPrepMaxTasks: 1);
var svc = NewService();
var first = await SeedIdleTask("a");
var second = await SeedIdleTask("b");
await svc.SetMyDay(first, true, null, CancellationToken.None);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.SetMyDay(second, true, null, CancellationToken.None));
Assert.Contains("limit", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SetMyDay_unset_is_always_allowed()
{
var svc = NewService();
var id = await SeedIdleTask("a");
await svc.SetMyDay(id, true, null, CancellationToken.None);
var dto = await svc.SetMyDay(id, false, null, CancellationToken.None);
Assert.False(dto.IsMyDay);
}
} }