feat(daily-prep): add get_daily_prep_candidates MCP tool
This commit is contained in:
@@ -49,6 +49,15 @@ public sealed record WorktreeListItemDto(
|
||||
public sealed record CleanupWorktreeResult(
|
||||
bool Removed, string WorktreePath, bool BranchDeleted);
|
||||
|
||||
public sealed record DailyPrepCandidateDto(
|
||||
string Id, string ListId, string ListName, string Title, string? Description,
|
||||
bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);
|
||||
|
||||
public sealed record DailyPrepDataDto(
|
||||
int MaxTasks,
|
||||
IReadOnlyList<DailyPrepCandidateDto> Candidates,
|
||||
IReadOnlyList<DailyPrepCandidateDto> CurrentMyDay);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class ExternalMcpService
|
||||
{
|
||||
@@ -482,6 +491,48 @@ public sealed class ExternalMcpService
|
||||
return new CleanupWorktreeResult(result.Removed, path, result.Removed);
|
||||
}
|
||||
|
||||
// ── Daily prep ───────────────────────────────────────────────────────────
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Daily prep: returns the open tasks eligible for today's MyDay selection. " +
|
||||
"candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " +
|
||||
"currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " +
|
||||
"maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")]
|
||||
public async Task<DailyPrepDataDto> GetDailyPrepCandidates(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
|
||||
var excludes = DailyPrepFilter.ParseExcludes(settings.ReportExcludedPaths);
|
||||
var maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
|
||||
|
||||
var idle = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.List)
|
||||
.Where(t => t.Status == TaskStatus.Idle)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var currentMyDay = idle
|
||||
.Where(t => t.IsMyDay)
|
||||
.OrderBy(t => t.SortOrder)
|
||||
.Select(ToCandidate)
|
||||
.ToList();
|
||||
|
||||
var candidates = idle
|
||||
.Where(t => !t.IsMyDay
|
||||
&& t.BlockedByTaskId == null
|
||||
&& DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
|
||||
.OrderBy(t => t.CreatedAt)
|
||||
.Select(ToCandidate)
|
||||
.ToList();
|
||||
|
||||
return new DailyPrepDataDto(maxTasks, candidates, currentMyDay);
|
||||
}
|
||||
|
||||
private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new(
|
||||
t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description,
|
||||
t.IsStarred, t.ScheduledFor, t.CreatedAt);
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync(
|
||||
@@ -569,3 +620,27 @@ public sealed class ExternalMcpService
|
||||
t.StartedAt,
|
||||
t.FinishedAt);
|
||||
}
|
||||
|
||||
internal static class DailyPrepFilter
|
||||
{
|
||||
public static string[] ParseExcludes(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return [];
|
||||
try
|
||||
{
|
||||
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
|
||||
return list is null ? [] : list.Select(Normalize).Where(p => p.Length > 0).ToArray();
|
||||
}
|
||||
catch (System.Text.Json.JsonException) { return []; }
|
||||
}
|
||||
|
||||
public static bool IsIncludedRepo(string? workingDir, string[] excludes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(workingDir)) return false;
|
||||
var norm = Normalize(workingDir);
|
||||
return !excludes.Any(p => norm.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string Normalize(string path) =>
|
||||
path.Trim().Replace('/', '\\').TrimEnd('\\');
|
||||
}
|
||||
|
||||
@@ -253,4 +253,117 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.DeleteTask("does-not-exist", CancellationToken.None));
|
||||
}
|
||||
|
||||
private async Task SeedAppSettingsAsync(string? reportExcludedPaths, int dailyPrepMaxTasks = 5)
|
||||
{
|
||||
var settings = new AppSettingsEntity
|
||||
{
|
||||
Id = AppSettingsEntity.SingletonId,
|
||||
ReportExcludedPaths = reportExcludedPaths,
|
||||
DailyPrepMaxTasks = dailyPrepMaxTasks,
|
||||
};
|
||||
// Upsert via AppSettingsRepository
|
||||
await using var ctx = _db.CreateContext();
|
||||
var repo = new AppSettingsRepository(ctx);
|
||||
await repo.UpdateAsync(settings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDailyPrepCandidates_filters_by_status_block_and_excluded_repo()
|
||||
{
|
||||
// List 1: included repo D:\work\repo — 4 tasks seeded
|
||||
var listId1 = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId1, Name = "Work", WorkingDir = @"D:\work\repo", CreatedAt = DateTime.UtcNow });
|
||||
|
||||
// idle, not blocked, not MyDay → should be candidate
|
||||
var idleUnblocked = new TaskEntity
|
||||
{
|
||||
Id = "idle-unblocked",
|
||||
ListId = listId1,
|
||||
Title = "Idle unblocked",
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(idleUnblocked);
|
||||
|
||||
// idle but blocked → excluded (BlockedByTaskId references idle-unblocked as its predecessor)
|
||||
var idleBlocked = new TaskEntity
|
||||
{
|
||||
Id = "idle-blocked",
|
||||
ListId = listId1,
|
||||
Title = "Idle blocked",
|
||||
Status = TaskStatus.Idle,
|
||||
BlockedByTaskId = "idle-unblocked",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(idleBlocked);
|
||||
|
||||
// Done → excluded
|
||||
var doneTask = new TaskEntity
|
||||
{
|
||||
Id = "done-task",
|
||||
ListId = listId1,
|
||||
Title = "Done task",
|
||||
Status = TaskStatus.Done,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(doneTask);
|
||||
|
||||
// idle, IsMyDay → goes into currentMyDay, not candidates
|
||||
var myDayTask = new TaskEntity
|
||||
{
|
||||
Id = "myday-task",
|
||||
ListId = listId1,
|
||||
Title = "MyDay task",
|
||||
Status = TaskStatus.Idle,
|
||||
IsMyDay = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(myDayTask);
|
||||
|
||||
// List 2: excluded repo C:\Private\secret
|
||||
var listId2 = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId2, Name = "Secret", WorkingDir = @"C:\Private\secret", CreatedAt = DateTime.UtcNow });
|
||||
|
||||
var excludedRepoTask = new TaskEntity
|
||||
{
|
||||
Id = "excluded-repo-task",
|
||||
ListId = listId2,
|
||||
Title = "Excluded repo",
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(excludedRepoTask);
|
||||
|
||||
// List 3: no WorkingDir → excluded
|
||||
var listId3 = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId3, Name = "NoRepo", WorkingDir = null, CreatedAt = DateTime.UtcNow });
|
||||
|
||||
var noRepoTask = new TaskEntity
|
||||
{
|
||||
Id = "no-repo-task",
|
||||
ListId = listId3,
|
||||
Title = "No repo",
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(noRepoTask);
|
||||
|
||||
await SeedAppSettingsAsync(@"[""C:\\Private""]", dailyPrepMaxTasks: 5);
|
||||
|
||||
var sut = BuildSut(CreateQueue());
|
||||
var result = await sut.GetDailyPrepCandidates(CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Candidates);
|
||||
Assert.Equal("idle-unblocked", result.Candidates[0].Id);
|
||||
Assert.Single(result.CurrentMyDay);
|
||||
Assert.Equal("myday-task", result.CurrentMyDay[0].Id);
|
||||
Assert.Equal(5, result.MaxTasks);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user