tools/mcp/dotnet/AzureSDKDevToolsMCP/Services/DevOpsService.cs (242 lines of code) (raw):

using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using Azure.Core; using Azure.Identity; using AzureSDKDSpecTools.Models; using Microsoft.Extensions.Logging; using Microsoft.TeamFoundation.Build.WebApi; using Microsoft.TeamFoundation.Work.WebApi; using Microsoft.TeamFoundation.WorkItemTracking.WebApi; using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; using Microsoft.VisualStudio.Services.Common; using Microsoft.VisualStudio.Services.OAuth; using Microsoft.VisualStudio.Services.WebApi; using Microsoft.VisualStudio.Services.WebApi.Patch; using Microsoft.VisualStudio.Services.WebApi.Patch.Json; using Newtonsoft.Json.Linq; namespace AzureSDKDSpecTools.Services { public interface IDevOpsService { public Task<ReleasePlan> GetReleasePlan(int workItemId); public Task<List<ReleasePlan>> GetReleasePlans(Guid serviceTreeId, Guid productTreeId, string pullRequest); public Task<WorkItem> CreateReleasePlanWorkItem(ReleasePlan releasePlan); } public class DevOpsService : IDevOpsService { private static readonly string devOpsUrl = "https://dev.azure.com/azure-sdk"; private static readonly string releaseProject = "release"; private BuildHttpClient buildClient; private WorkItemTrackingHttpClient workItemClient; private AccessToken token; private ILogger<DevOpsService> logger; public DevOpsService(ILogger<DevOpsService> _logger) { logger = _logger; // Connect to Azure DevOps using managed identity token = GetToken(); var connection = new VssConnection(new Uri(devOpsUrl), new VssOAuthAccessTokenCredential(token.Token)); buildClient = connection.GetClient<BuildHttpClient>(); workItemClient = connection.GetClient<WorkItemTrackingHttpClient>(); } private static AccessToken GetToken() { return (new DefaultAzureCredential()).GetToken(new TokenRequestContext(["499b84ac-1321-427f-aa17-267ca6975798/.default"])); } private void RefreshConnection() { if (token.ExpiresOn < DateTimeOffset.Now.AddMinutes(5)) { token = GetToken(); var connection = new VssConnection(new Uri(devOpsUrl), new VssOAuthAccessTokenCredential(token.Token)); buildClient = connection.GetClient<BuildHttpClient>(); workItemClient = connection.GetClient<WorkItemTrackingHttpClient>(); } } public async Task<ReleasePlan> GetReleasePlan(int workItemId) { RefreshConnection(); logger.LogInformation($"Fetching release plan work with id {workItemId}"); var workItem = await workItemClient.GetWorkItemAsync(workItemId); if (workItem?.Id == null) throw new InvalidOperationException($"Work item {workItemId} not found."); var releasePlan = MapWorkItemToReleasePlan(workItem); releasePlan.WorkItemUrl = workItem.Url; releasePlan.WorkItemId = workItem?.Id ?? 0; return releasePlan; } private static ReleasePlan MapWorkItemToReleasePlan(WorkItem workItem) { var releasePlan = new ReleasePlan() { WorkItemId = workItem.Id ?? 0, WorkItemUrl = workItem.Url, Title = workItem.Fields.TryGetValue("System.Title", out object? value) ? value?.ToString() ?? string.Empty : string.Empty, Status = workItem.Fields.TryGetValue("System.State", out value) ? value?.ToString() ?? string.Empty : string.Empty, ServiceTreeId = workItem.Fields.TryGetValue("Custom.ServiceTreeID", out value) ? value?.ToString() ?? string.Empty : string.Empty, ProductTreeId = workItem.Fields.TryGetValue("Custom.ProductServiceTreeID", out value) ? value?.ToString() ?? string.Empty : string.Empty, SDKReleaseMonth = workItem.Fields.TryGetValue("Custom.SDKReleaseMonth", out value) ? value?.ToString() ?? string.Empty : string.Empty, IsManagementPlane = workItem.Fields.TryGetValue("Custom.MgmtScope", out value) ? value?.ToString() == "Yes" : false, IsDataPlane = workItem.Fields.TryGetValue("Custom.DataScope", out value) ? value?.ToString() == "Yes" : false }; return releasePlan; } public async Task<List<ReleasePlan>> GetReleasePlans(Guid serviceTreeId, Guid productTreeId, string pullRequest) { RefreshConnection(); var query = $"SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = '{releaseProject}' AND [System.WorkItemType] = 'Release Plan' AND [Custom.ServiceTreeID] = '{serviceTreeId}' AND [Custom.ProductServiceTreeID] = '{productTreeId}' AND [System.State] NOT IN ('Abandoned', 'Duplicate', 'Finished')"; var workItems = await FetchWorkItems(query); List<ReleasePlan> releasePlans = new (); logger.LogInformation($"Fetched {workItems.Count} release plans for service and product."); foreach (var workItem in workItems) { if (workItem.Relations == null) continue; // Find API spec work item var apiSpecRelations = workItem.Relations.Where(r => r.Rel.Equals("System.LinkTypes.Hierarchy-Forward")); foreach(var apiSpecRelation in apiSpecRelations) { var apiSpecWorkItemId = int.Parse(apiSpecRelation.Url.Split('/').Last()); var apiSpecWorkItem = await workItemClient.GetWorkItemAsync(apiSpecWorkItemId); if (apiSpecWorkItem != null) { // Find all spec pull requests added in API spec work item. if (apiSpecWorkItem.Fields.TryGetValue("Custom.RESTAPIReviews", out Object? value)) { var restApiReviews = value?.ToString(); if (restApiReviews != null) { var pullRequests = ParsePullRequestLinks(restApiReviews); if (pullRequests.Contains(pullRequest.ToLower())) { var releasePlan = MapWorkItemToReleasePlan(workItem); releasePlan.SpecPullRequests.AddRange(pullRequests); releasePlans.Add(releasePlan); } } } } } } return releasePlans; } private static HashSet<string> ParsePullRequestLinks(string htmlText) { // This method parses pull requiest links from html text like // "<a href=\"https://github.com/Azure/azure-rest-api-specs/pull/33459\">https://github.com/Azure/azure-rest-api-specs/pull/33459</a><br><a href=\"https://github.com/Azure/azure-rest-api-specs/pull/32282\">https://github.com/Azure/azure-rest-api-specs/pull/32282</a><br" HashSet<string> links = new HashSet<string>(); var regex = new Regex("https:\\/\\/github\\.com\\/[\\w-]+\\/[\\w-]+\\/pull\\/\\d+"); links.AddRange(regex.Matches(htmlText).Select(m => m.Value.ToLower())); return links; } public async Task<WorkItem> CreateReleasePlanWorkItem(ReleasePlan releasePlan) { int releasePlanWorkItemId = 0; int apiSpecWorkItemId = 0; try { RefreshConnection(); // Create release plan work item var releasePlanTitle = $"Release plan for {releasePlan.ProductName ?? releasePlan.ProductTreeId}"; WorkItem releasePlanWorkItem = await CreateWorkItem(releasePlan, "Release Plan", releasePlanTitle); releasePlanWorkItemId = releasePlanWorkItem?.Id ?? 0; if (releasePlanWorkItemId == 0) { throw new Exception("Failed to create release plan work item"); } // Create API spec work item var apiSpecTitle = $"API spec for {releasePlan.ProductName ?? releasePlan.ProductTreeId} - version {releasePlan.SpecAPIVersion}"; var apiSpecWorkItem = await CreateWorkItem(releasePlan, "API Spec", apiSpecTitle); apiSpecWorkItemId = apiSpecWorkItem.Id ?? 0; if (apiSpecWorkItemId == 0) { throw new Exception("Failed to create API spec work item"); } // Link API spec as child of release plan await LinkWorkItemAsChild(releasePlanWorkItemId, apiSpecWorkItem.Url); if (releasePlanWorkItem != null) return releasePlanWorkItem; throw new Exception("Failed to create API spec work item"); } catch (Exception ex) { var errorMesage = $"Failed to create release plan and API spec work items, Error:{ex.Message}"; logger.LogError(errorMesage); // Delete created work items if both release plan and API spec work items were not created and linked if (releasePlanWorkItemId != 0) await workItemClient.DeleteWorkItemAsync(releasePlanWorkItemId); if (apiSpecWorkItemId != 0) await workItemClient.DeleteWorkItemAsync(apiSpecWorkItemId); throw new Exception (errorMesage); } } private async Task<WorkItem> CreateWorkItem(ReleasePlan releasePlan, string workItemType, string title) { var specDocument = releasePlan.GetPatchDocument(); specDocument.Add(new JsonPatchOperation { Operation = Operation.Add, Path = "/fields/System.Title", Value = title }); if (workItemType == "API Spec" && releasePlan.SpecPullRequests.Count > 0) { StringBuilder sb = new StringBuilder(); foreach(var pr in releasePlan.SpecPullRequests) { if (sb.Length > 0) sb.Append("<br>"); sb.Append($"<a href=\"{pr}\">{pr}</a>"); } var prLinks = sb.ToString(); logger.LogInformation($"Adding pull request {prLinks} to API spec work item."); specDocument.Add(new JsonPatchOperation { Operation = Operation.Add, Path = "/fields/Custom.RESTAPIReviews", Value = sb.ToString() }); } logger.LogInformation($"Creating {workItemType} work item"); var workItem = await workItemClient.CreateWorkItemAsync(specDocument, releaseProject, workItemType); if (workItem == null) { throw new Exception("Failed to create Work Item"); } return workItem; } private async Task LinkWorkItemAsChild(int parentId, string childUrl) { try { // Add work item as child of release plan work item var jsonLinkDocument = new JsonPatchDocument { new JsonPatchOperation { Operation = Operation.Add, Path = "/relations/-", Value = new WorkItemRelation { Rel = "System.LinkTypes.Hierarchy-Forward", Url = childUrl } } }; await workItemClient.UpdateWorkItemAsync(jsonLinkDocument, parentId); } catch (Exception ex) { var errorMesage = $"Failed to link work item {childUrl} as child of {parentId}, Error: {ex.Message}"; throw new Exception (errorMesage); } } private async Task<List<WorkItem>> FetchWorkItems(string query) { var result = await workItemClient.QueryByWiqlAsync(new Wiql { Query = query }); if (result != null && result.WorkItems != null) { return await workItemClient.GetWorkItemsAsync(result.WorkItems.Select(wi => wi.Id), expand: WorkItemExpand.Relations); } else { logger.LogWarning("No work items found."); return []; } } } }