From 291c5203f2e442fa81f96cc6b5eacb0cef7bc454 Mon Sep 17 00:00:00 2001 From: viamu Date: Tue, 10 Feb 2026 15:29:56 -0300 Subject: [PATCH] feat: add create_work_item and update_work_item MCP tools Add two new write operations for Azure DevOps work items: - create_work_item: creates work items with all standard fields, parent linking, tags, and custom fields via additionalFields JSON - update_work_item: partial updates with null-means-no-change semantics Includes service layer implementation using JsonPatchDocument, 15 unit tests, and updated documentation. Co-Authored-By: Claude Opus 4.6 --- CONTRIBUTING.md | 2 +- README.md | 6 + .../Services/AzureDevOpsService.cs | 286 ++++++++++++++++++ .../Services/IAzureDevOpsService.cs | 62 ++++ .../Tools/WorkItemTools.cs | 126 ++++++++ .../Tools/WorkItemToolsTests.cs | 226 ++++++++++++++ 6 files changed, 707 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 502e8da..2c06fb8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -299,7 +299,7 @@ src/Viamus.Azure.Devops.Mcp.Server/ │ ├── IAzureDevOpsService.cs # Service interface │ └── AzureDevOpsService.cs # Implementation ├── Tools/ -│ ├── WorkItemTools.cs # Work Item tools (9) +│ ├── WorkItemTools.cs # Work Item tools (11) │ ├── GitTools.cs # Git Repository tools (6) │ ├── PullRequestTools.cs # Pull Request tools (5) │ └── PipelineTools.cs # Pipeline/Build tools (9) diff --git a/README.md b/README.md index 61056eb..725bc24 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,8 @@ This project implements an MCP server that exposes tools for querying and managi | `get_recent_work_items` | Gets recently changed work items | | `search_work_items` | Searches work items by title text | | `add_work_item_comment` | Adds a comment to a specific work item | +| `create_work_item` | Creates a new work item (Bug, Task, User Story, etc.) with support for all standard fields, parent linking, and custom fields | +| `update_work_item` | Updates an existing work item. Only specified fields are changed; omitted fields remain unchanged | ### Git Repository Tools @@ -377,6 +379,10 @@ After configuring the MCP client, you can ask questions like: - "What work items were changed in the last 7 days?" - "Search for work items with 'login' in the title" - "Add a comment to work item #1234 saying the bug was fixed" +- "Create a new Bug in project X titled 'Login page crashes on submit'" +- "Create a User Story assigned to John with priority 2 under parent #100" +- "Update work item #1234 to change state to 'Resolved' and assign to Jane" +- "Set the iteration path of work item #567 to 'Project\Sprint 3'" ### Git Repositories diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs index de452da..47f1989 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/AzureDevOpsService.cs @@ -5,6 +5,8 @@ using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; using Microsoft.VisualStudio.Services.Common; using Microsoft.VisualStudio.Services.WebApi; +using Microsoft.VisualStudio.Services.WebApi.Patch; +using Microsoft.VisualStudio.Services.WebApi.Patch.Json; using Viamus.Azure.Devops.Mcp.Server.Configuration; using Viamus.Azure.Devops.Mcp.Server.Models; @@ -491,6 +493,290 @@ public async Task AddWorkItemCommentAsync( } } + public async Task CreateWorkItemAsync( + string project, + string workItemType, + string title, + string? description = null, + string? assignedTo = null, + string? areaPath = null, + string? iterationPath = null, + string? state = null, + int? priority = null, + int? parentId = null, + string? tags = null, + Dictionary? additionalFields = null, + CancellationToken cancellationToken = default) + { + try + { + _logger.LogDebug("Creating work item of type {WorkItemType} in project {Project}", workItemType, project); + + var patchDocument = new JsonPatchDocument(); + + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Add, + Path = "/fields/System.Title", + Value = title + }); + + if (!string.IsNullOrEmpty(description)) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Add, + Path = "/fields/System.Description", + Value = description + }); + } + + if (!string.IsNullOrEmpty(assignedTo)) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Add, + Path = "/fields/System.AssignedTo", + Value = assignedTo + }); + } + + if (!string.IsNullOrEmpty(areaPath)) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Add, + Path = "/fields/System.AreaPath", + Value = areaPath + }); + } + + if (!string.IsNullOrEmpty(iterationPath)) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Add, + Path = "/fields/System.IterationPath", + Value = iterationPath + }); + } + + if (!string.IsNullOrEmpty(state)) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Add, + Path = "/fields/System.State", + Value = state + }); + } + + if (priority.HasValue) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Add, + Path = "/fields/Microsoft.VSTS.Common.Priority", + Value = priority.Value + }); + } + + if (!string.IsNullOrEmpty(tags)) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Add, + Path = "/fields/System.Tags", + Value = tags + }); + } + + if (parentId.HasValue) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Add, + Path = "/relations/-", + Value = new + { + rel = "System.LinkTypes.Hierarchy-Reverse", + url = $"{_options.OrganizationUrl}/_apis/wit/workItems/{parentId.Value}", + attributes = new { comment = "Parent" } + } + }); + } + + if (additionalFields != null) + { + foreach (var field in additionalFields) + { + var path = field.Key.StartsWith("/fields/") + ? field.Key + : $"/fields/{field.Key}"; + + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Add, + Path = path, + Value = field.Value + }); + } + } + + var result = await _witClient.CreateWorkItemAsync( + document: patchDocument, + project: project, + type: workItemType, + cancellationToken: cancellationToken); + + return MapToDto(result, includeAllFields: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating work item of type {WorkItemType} in project {Project}", workItemType, project); + throw; + } + } + + public async Task UpdateWorkItemAsync( + int workItemId, + string? title = null, + string? description = null, + string? assignedTo = null, + string? state = null, + string? areaPath = null, + string? iterationPath = null, + int? priority = null, + string? tags = null, + Dictionary? additionalFields = null, + string? project = null, + CancellationToken cancellationToken = default) + { + try + { + _logger.LogDebug("Updating work item {WorkItemId}", workItemId); + + var patchDocument = new JsonPatchDocument(); + + if (title != null) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Replace, + Path = "/fields/System.Title", + Value = title + }); + } + + if (description != null) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Replace, + Path = "/fields/System.Description", + Value = description + }); + } + + if (assignedTo != null) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Replace, + Path = "/fields/System.AssignedTo", + Value = assignedTo + }); + } + + if (state != null) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Replace, + Path = "/fields/System.State", + Value = state + }); + } + + if (areaPath != null) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Replace, + Path = "/fields/System.AreaPath", + Value = areaPath + }); + } + + if (iterationPath != null) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Replace, + Path = "/fields/System.IterationPath", + Value = iterationPath + }); + } + + if (priority.HasValue) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Replace, + Path = "/fields/Microsoft.VSTS.Common.Priority", + Value = priority.Value + }); + } + + if (tags != null) + { + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Replace, + Path = "/fields/System.Tags", + Value = tags + }); + } + + if (additionalFields != null) + { + foreach (var field in additionalFields) + { + var path = field.Key.StartsWith("/fields/") + ? field.Key + : $"/fields/{field.Key}"; + + patchDocument.Add(new JsonPatchOperation + { + Operation = Operation.Replace, + Path = path, + Value = field.Value + }); + } + } + + // If no fields to update, just return the current work item + if (patchDocument.Count == 0) + { + var currentWorkItem = await GetWorkItemAsync(workItemId, project, cancellationToken); + return currentWorkItem!; + } + + var result = await _witClient.UpdateWorkItemAsync( + document: patchDocument, + id: workItemId, + project: project ?? _options.DefaultProject, + cancellationToken: cancellationToken); + + return MapToDto(result, includeAllFields: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating work item {WorkItemId}", workItemId); + throw; + } + } + #region Git Operations public async Task> GetRepositoriesAsync(string? project = null, CancellationToken cancellationToken = default) diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs index f85a309..dbf73dd 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Services/IAzureDevOpsService.cs @@ -75,6 +75,68 @@ Task AddWorkItemCommentAsync( string? project = null, CancellationToken cancellationToken = default); + /// + /// Creates a new work item in the specified project. + /// + /// The project name. + /// The work item type (e.g., Bug, Task, User Story). + /// The work item title. + /// Optional description. + /// Optional user to assign to. + /// Optional area path. + /// Optional iteration path. + /// Optional initial state. + /// Optional priority (1-4). + /// Optional parent work item ID to link as child. + /// Optional semicolon-separated tags. + /// Optional dictionary of additional field reference names and values. + /// Cancellation token. + /// The created work item. + Task CreateWorkItemAsync( + string project, + string workItemType, + string title, + string? description = null, + string? assignedTo = null, + string? areaPath = null, + string? iterationPath = null, + string? state = null, + int? priority = null, + int? parentId = null, + string? tags = null, + Dictionary? additionalFields = null, + CancellationToken cancellationToken = default); + + /// + /// Updates an existing work item. + /// + /// The work item ID to update. + /// Optional new title. + /// Optional new description. + /// Optional new assignee. + /// Optional new state. + /// Optional new area path. + /// Optional new iteration path. + /// Optional new priority (1-4). + /// Optional new tags. + /// Optional dictionary of additional field reference names and values. + /// The project name (optional if default project is configured). + /// Cancellation token. + /// The updated work item. + Task UpdateWorkItemAsync( + int workItemId, + string? title = null, + string? description = null, + string? assignedTo = null, + string? state = null, + string? areaPath = null, + string? iterationPath = null, + int? priority = null, + string? tags = null, + Dictionary? additionalFields = null, + string? project = null, + CancellationToken cancellationToken = default); + #region Git Operations /// diff --git a/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs b/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs index 310dc2a..5c9f98c 100644 --- a/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs +++ b/src/Viamus.Azure.Devops.Mcp.Server/Tools/WorkItemTools.cs @@ -255,6 +255,132 @@ public async Task AddWorkItemComment( }, JsonOptions); } + [McpServerTool(Name = "create_work_item")] + [Description("Creates a new work item in Azure DevOps. Supports setting all standard fields plus custom fields via additionalFields.")] + public async Task CreateWorkItem( + [Description("The project name where the work item will be created")] string project, + [Description("The type of work item to create (e.g., 'Bug', 'Task', 'User Story', 'Feature', 'Epic')")] string workItemType, + [Description("The title of the work item")] string title, + [Description("The description of the work item (supports HTML)")] string? description = null, + [Description("The display name or email of the user to assign the work item to")] string? assignedTo = null, + [Description("The area path for the work item")] string? areaPath = null, + [Description("The iteration path for the work item")] string? iterationPath = null, + [Description("The initial state of the work item (e.g., 'New', 'Active')")] string? state = null, + [Description("The priority of the work item (1-4, where 1 is highest)")] int? priority = null, + [Description("The ID of the parent work item to link to")] int? parentId = null, + [Description("Semicolon-separated tags (e.g., 'tag1; tag2; tag3')")] string? tags = null, + [Description("JSON string of additional fields as key-value pairs (e.g., '{\"Custom.Field\": \"value\"}')")] string? additionalFields = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(project)) + { + return JsonSerializer.Serialize(new { error = "Project name is required" }, JsonOptions); + } + + if (string.IsNullOrWhiteSpace(workItemType)) + { + return JsonSerializer.Serialize(new { error = "Work item type is required" }, JsonOptions); + } + + if (string.IsNullOrWhiteSpace(title)) + { + return JsonSerializer.Serialize(new { error = "Title is required" }, JsonOptions); + } + + if (priority.HasValue && (priority.Value < 1 || priority.Value > 4)) + { + return JsonSerializer.Serialize(new { error = "Priority must be between 1 and 4" }, JsonOptions); + } + + Dictionary? parsedAdditionalFields = null; + if (!string.IsNullOrWhiteSpace(additionalFields)) + { + parsedAdditionalFields = ParseAdditionalFields(additionalFields); + if (parsedAdditionalFields == null) + { + return JsonSerializer.Serialize(new { error = "Invalid JSON format for additionalFields" }, JsonOptions); + } + } + + var workItem = await _azureDevOpsService.CreateWorkItemAsync( + project, workItemType, title, description, assignedTo, + areaPath, iterationPath, state, priority, parentId, tags, + parsedAdditionalFields, cancellationToken); + + return JsonSerializer.Serialize(new + { + success = true, + message = $"Work item {workItem.Id} created successfully", + workItem + }, JsonOptions); + } + + [McpServerTool(Name = "update_work_item")] + [Description("Updates an existing Azure DevOps work item. Only specified fields will be updated; omitted fields remain unchanged.")] + public async Task UpdateWorkItem( + [Description("The ID of the work item to update")] int workItemId, + [Description("The project name (optional if default project is configured)")] string? project = null, + [Description("New title for the work item")] string? title = null, + [Description("New description for the work item (supports HTML)")] string? description = null, + [Description("New assignee display name or email")] string? assignedTo = null, + [Description("New state for the work item (e.g., 'Active', 'Closed', 'Resolved')")] string? state = null, + [Description("New area path")] string? areaPath = null, + [Description("New iteration path")] string? iterationPath = null, + [Description("New priority (1-4, where 1 is highest)")] int? priority = null, + [Description("New semicolon-separated tags (e.g., 'tag1; tag2; tag3')")] string? tags = null, + [Description("JSON string of additional fields as key-value pairs (e.g., '{\"Custom.Field\": \"value\"}')")] string? additionalFields = null, + CancellationToken cancellationToken = default) + { + if (workItemId <= 0) + { + return JsonSerializer.Serialize(new { error = "Work item ID must be a positive integer" }, JsonOptions); + } + + if (priority.HasValue && (priority.Value < 1 || priority.Value > 4)) + { + return JsonSerializer.Serialize(new { error = "Priority must be between 1 and 4" }, JsonOptions); + } + + Dictionary? parsedAdditionalFields = null; + if (!string.IsNullOrWhiteSpace(additionalFields)) + { + parsedAdditionalFields = ParseAdditionalFields(additionalFields); + if (parsedAdditionalFields == null) + { + return JsonSerializer.Serialize(new { error = "Invalid JSON format for additionalFields" }, JsonOptions); + } + } + + var workItem = await _azureDevOpsService.UpdateWorkItemAsync( + workItemId, title, description, assignedTo, state, + areaPath, iterationPath, priority, tags, + parsedAdditionalFields, project, cancellationToken); + + return JsonSerializer.Serialize(new + { + success = true, + message = $"Work item {workItem.Id} updated successfully", + workItem + }, JsonOptions); + } + + private static Dictionary? ParseAdditionalFields(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize>(json); + } + catch (JsonException) + { + return null; + } + } + private static List ParseWorkItemIds(string workItemIds) { if (string.IsNullOrWhiteSpace(workItemIds)) diff --git a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/WorkItemToolsTests.cs b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/WorkItemToolsTests.cs index c6d0238..27138e2 100644 --- a/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/WorkItemToolsTests.cs +++ b/tests/Viamus.Azure.Devops.Mcp.Server.Tests/Tools/WorkItemToolsTests.cs @@ -613,4 +613,230 @@ public async Task AddWorkItemComment_WithProject_ShouldPassProjectToService() } #endregion + + #region CreateWorkItem Tests + + [Fact] + public async Task CreateWorkItem_WithRequiredFieldsOnly_ShouldReturnSuccess() + { + var workItem = new WorkItemDto + { + Id = 100, + Title = "New Task", + WorkItemType = "Task", + State = "New" + }; + + _mockService + .Setup(s => s.CreateWorkItemAsync( + "MyProject", "Task", "New Task", + null, null, null, null, null, null, null, null, null, + It.IsAny())) + .ReturnsAsync(workItem); + + var result = await _tools.CreateWorkItem("MyProject", "Task", "New Task"); + + Assert.Contains("\"success\": true", result); + Assert.Contains("Work item 100 created successfully", result); + Assert.Contains("\"id\": 100", result); + } + + [Fact] + public async Task CreateWorkItem_WithAllFields_ShouldPassAllFieldsToService() + { + var workItem = new WorkItemDto { Id = 101, Title = "Full Task" }; + + _mockService + .Setup(s => s.CreateWorkItemAsync( + "MyProject", "User Story", "Full Task", + "A description", "user@test.com", "MyProject\\Area", + "MyProject\\Sprint 1", "Active", 2, 50, "tag1; tag2", + It.Is>(d => d.ContainsKey("Custom.Field")), + It.IsAny())) + .ReturnsAsync(workItem); + + var result = await _tools.CreateWorkItem( + "MyProject", "User Story", "Full Task", + description: "A description", + assignedTo: "user@test.com", + areaPath: "MyProject\\Area", + iterationPath: "MyProject\\Sprint 1", + state: "Active", + priority: 2, + parentId: 50, + tags: "tag1; tag2", + additionalFields: "{\"Custom.Field\": \"value\"}"); + + Assert.Contains("\"success\": true", result); + _mockService.Verify(s => s.CreateWorkItemAsync( + "MyProject", "User Story", "Full Task", + "A description", "user@test.com", "MyProject\\Area", + "MyProject\\Sprint 1", "Active", 2, 50, "tag1; tag2", + It.Is>(d => d["Custom.Field"] == "value"), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task CreateWorkItem_WithEmptyProject_ShouldReturnError() + { + var result = await _tools.CreateWorkItem("", "Task", "Title"); + + Assert.Contains("error", result); + Assert.Contains("Project name is required", result); + } + + [Fact] + public async Task CreateWorkItem_WithEmptyTitle_ShouldReturnError() + { + var result = await _tools.CreateWorkItem("MyProject", "Task", ""); + + Assert.Contains("error", result); + Assert.Contains("Title is required", result); + } + + [Fact] + public async Task CreateWorkItem_WithEmptyWorkItemType_ShouldReturnError() + { + var result = await _tools.CreateWorkItem("MyProject", "", "Title"); + + Assert.Contains("error", result); + Assert.Contains("Work item type is required", result); + } + + [Fact] + public async Task CreateWorkItem_WithInvalidPriority_ShouldReturnError() + { + var result = await _tools.CreateWorkItem("MyProject", "Task", "Title", priority: 5); + + Assert.Contains("error", result); + Assert.Contains("Priority must be between 1 and 4", result); + } + + [Fact] + public async Task CreateWorkItem_WithInvalidAdditionalFieldsJson_ShouldReturnError() + { + var result = await _tools.CreateWorkItem("MyProject", "Task", "Title", additionalFields: "not json"); + + Assert.Contains("error", result); + Assert.Contains("Invalid JSON format for additionalFields", result); + } + + [Fact] + public async Task CreateWorkItem_WithValidAdditionalFieldsJson_ShouldParseAndPassToService() + { + var workItem = new WorkItemDto { Id = 102, Title = "Task" }; + + _mockService + .Setup(s => s.CreateWorkItemAsync( + "MyProject", "Task", "Task", + null, null, null, null, null, null, null, null, + It.Is>(d => d["Custom.Field1"] == "val1" && d["Custom.Field2"] == "val2"), + It.IsAny())) + .ReturnsAsync(workItem); + + var result = await _tools.CreateWorkItem("MyProject", "Task", "Task", + additionalFields: "{\"Custom.Field1\": \"val1\", \"Custom.Field2\": \"val2\"}"); + + Assert.Contains("\"success\": true", result); + } + + #endregion + + #region UpdateWorkItem Tests + + [Fact] + public async Task UpdateWorkItem_WithTitle_ShouldReturnSuccess() + { + var workItem = new WorkItemDto + { + Id = 200, + Title = "Updated Title", + State = "Active" + }; + + _mockService + .Setup(s => s.UpdateWorkItemAsync( + 200, "Updated Title", null, null, null, null, null, null, null, null, null, + It.IsAny())) + .ReturnsAsync(workItem); + + var result = await _tools.UpdateWorkItem(200, title: "Updated Title"); + + Assert.Contains("\"success\": true", result); + Assert.Contains("Work item 200 updated successfully", result); + } + + [Fact] + public async Task UpdateWorkItem_WithZeroId_ShouldReturnError() + { + var result = await _tools.UpdateWorkItem(0, title: "Title"); + + Assert.Contains("error", result); + Assert.Contains("Work item ID must be a positive integer", result); + } + + [Fact] + public async Task UpdateWorkItem_WithNegativeId_ShouldReturnError() + { + var result = await _tools.UpdateWorkItem(-1, title: "Title"); + + Assert.Contains("error", result); + Assert.Contains("Work item ID must be a positive integer", result); + } + + [Fact] + public async Task UpdateWorkItem_WithInvalidPriority_ShouldReturnError() + { + var result = await _tools.UpdateWorkItem(200, priority: 0); + + Assert.Contains("error", result); + Assert.Contains("Priority must be between 1 and 4", result); + } + + [Fact] + public async Task UpdateWorkItem_WithInvalidAdditionalFieldsJson_ShouldReturnError() + { + var result = await _tools.UpdateWorkItem(200, additionalFields: "{invalid}"); + + Assert.Contains("error", result); + Assert.Contains("Invalid JSON format for additionalFields", result); + } + + [Fact] + public async Task UpdateWorkItem_WithProject_ShouldPassProjectToService() + { + var workItem = new WorkItemDto { Id = 200, Title = "Title" }; + + _mockService + .Setup(s => s.UpdateWorkItemAsync( + 200, "Title", null, null, null, null, null, null, null, null, "SpecificProject", + It.IsAny())) + .ReturnsAsync(workItem); + + await _tools.UpdateWorkItem(200, project: "SpecificProject", title: "Title"); + + _mockService.Verify(s => s.UpdateWorkItemAsync( + 200, "Title", null, null, null, null, null, null, null, null, "SpecificProject", + It.IsAny()), Times.Once); + } + + [Fact] + public async Task UpdateWorkItem_WithValidAdditionalFieldsJson_ShouldParseAndPassToService() + { + var workItem = new WorkItemDto { Id = 200, Title = "Title" }; + + _mockService + .Setup(s => s.UpdateWorkItemAsync( + 200, null, null, null, null, null, null, null, null, + It.Is>(d => d["Custom.Field"] == "value"), + null, + It.IsAny())) + .ReturnsAsync(workItem); + + var result = await _tools.UpdateWorkItem(200, additionalFields: "{\"Custom.Field\": \"value\"}"); + + Assert.Contains("\"success\": true", result); + } + + #endregion }