feat(mcp/external): add UpdateTask for content/tag patching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-25 11:27:16 +02:00
parent 1a74e1c058
commit d99cb68afb
2 changed files with 80 additions and 0 deletions

View File

@@ -130,6 +130,33 @@ public sealed class ExternalMcpService
return ToDto(entity); return ToDto(entity);
} }
[McpServerTool, Description("Update an existing task's title, description, commit type, and/or tags. Pass null to leave a field unchanged. Tags are replaced as a full set when non-null. Refuses if the task is currently Running.")]
public async Task<TaskDto> UpdateTask(
string taskId,
string? title,
string? description,
string? commitType,
IReadOnlyList<string>? tags,
CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot update a running task. Cancel it first.");
if (title is not null) task.Title = title;
if (description is not null) task.Description = description;
if (commitType is not null) task.CommitType = commitType;
await _tasks.UpdateAsync(task, cancellationToken);
if (tags is not null)
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
return ToDto(reload);
}
[McpServerTool, Description("Update a task's status. Only 'Manual' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")] [McpServerTool, Description("Update a task's status. Only 'Manual' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")]
public async Task<TaskDto> UpdateTaskStatus( public async Task<TaskDto> UpdateTaskStatus(
string taskId, string taskId,

View File

@@ -172,4 +172,57 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Empty(await _tasks.GetTagsAsync(dto.Id)); Assert.Empty(await _tasks.GetTagsAsync(dto.Id));
} }
[Fact]
public async Task UpdateTask_PatchesNonNullFieldsOnly()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, "old title");
var queue = CreateQueue();
var sut = BuildSut(queue);
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None);
Assert.Equal("new title", dto.Title);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal("new title", loaded!.Title);
}
[Fact]
public async Task UpdateTask_TagsReplaceFullSet()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
var queue = CreateQueue();
var sut = BuildSut(queue);
await sut.UpdateTask(task.Id, null, null, null, new[] { "manual" }, CancellationToken.None);
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Single(tags);
Assert.Equal("manual", tags[0].Name);
}
[Fact]
public async Task UpdateTask_OnRunning_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
var queue = CreateQueue();
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None));
}
[Fact]
public async Task UpdateTask_NotFound_Throws()
{
var queue = CreateQueue();
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None));
}
} }