diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Mocks/Services/MockDevOpsService.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Mocks/Services/MockDevOpsService.cs index dac14901fb1..d7bbc6f1173 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Mocks/Services/MockDevOpsService.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Mocks/Services/MockDevOpsService.cs @@ -83,6 +83,8 @@ Task IDevOpsService.GetReleasePlanForWorkItemAsync(int workI Title = "Mock Release Plan", Description = "This is a mock release plan for testing purposes." }; + releasePlan.IsDataPlane = workItemId > 1000; + releasePlan.IsManagementPlane = !releasePlan.IsDataPlane; return Task.FromResult(releasePlan); } @@ -122,11 +124,11 @@ Task>> IDevOpsService.GetPipelineLlmArtifacts(st return Task.FromResult(new Dictionary>()); } - Task IDevOpsService.UpdateWorkItem(int workItemId, Dictionary fields) + Task IDevOpsService.UpdateWorkItemAsync(int workItemId, Dictionary fields) { var workItem = new WorkItem { - Id = 1, + Id = workItemId, Fields = new Dictionary { { "System.Title", "Updated work item" }, diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/ReleasePlanManualTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/ReleasePlanManualTests.cs new file mode 100644 index 00000000000..61ba2d8d1ba --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/ReleasePlanManualTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Azure.Sdk.Tools.Cli.Helpers; +using Azure.Sdk.Tools.Cli.Models; +using Azure.Sdk.Tools.Cli.Services; +using Azure.Sdk.Tools.Cli.Tests.Mocks.Services; +using Azure.Sdk.Tools.Cli.Tests.TestHelpers; +using Azure.Sdk.Tools.Cli.Tools.ReleasePlan; +using Microsoft.VisualStudio.TestPlatform.CoreUtilities.Helpers; +using Moq; + +namespace Azure.Sdk.Tools.Cli.Tests.Tools +{ + internal class ReleasePlanManualTests + { + private IAzureService azureService; + private IDevOpsService devOpsService; + private ReleasePlanTool releasePlan; + private TestLogger logger; + private IGitHubService gitHubService; + private ITypeSpecHelper typeSpecHelper; + private IUserHelper userHelper; + private IEnvironmentHelper environmentHelper; + + public ReleasePlanManualTests() + { + azureService = new AzureService(); + var devopsLogger = new TestLogger(); + var devopsConnection = new DevOpsConnection(azureService); + devOpsService = new DevOpsService(devopsLogger, devopsConnection); + + logger = new TestLogger(); + gitHubService = new Mock().Object; + + var typeSpecHelperMock = new Mock(); + typeSpecHelperMock.Setup(x => x.IsRepoPathForPublicSpecRepo(It.IsAny())).Returns(true); + typeSpecHelper = typeSpecHelperMock.Object; + + var userHelperMock = new Mock(); + userHelperMock.Setup(x => x.GetUserEmail()).ReturnsAsync("test@example.com"); + userHelper = userHelperMock.Object; + + var environmentHelperMock = new Mock(); + environmentHelperMock.Setup(x => x.GetBooleanVariable(It.IsAny(), It.IsAny())).Returns(false); + environmentHelper = environmentHelperMock.Object; + releasePlan = new ReleasePlanTool(devOpsService, typeSpecHelper, logger, userHelper, gitHubService, environmentHelper); + } + + [Test] // disabled by default because it makes real API calls + [Ignore("Manual test - requires real API calls")] + public async Task Test_UpdateExclusionJustification() + { + int releasePlanWorkItemId = 28940; // replace with a real release plan ID + string exclusionJustification = "Updated justification for exclusion."; + var updateStatus = await this.releasePlan.UpdateLanguageExclusionJustification(releasePlanWorkItemId, exclusionJustification); + Assert.IsNotNull(updateStatus); + Assert.That(updateStatus.Message, Does.Contain("Updated language exclusion")); + + var releasePlanInfo = await this.devOpsService.GetReleasePlanForWorkItemAsync(releasePlanWorkItemId); + Assert.That(exclusionJustification, Is.EqualTo(releasePlanInfo.LanguageExclusionRequesterNote)); + } + + [Test] // disabled by default because it makes real API calls + [Ignore("Manual test - requires real API calls")] + public async Task Test_UpdateExclusionJustificationWithLanguage() + { + int releasePlanWorkItemId = 28940; // replace with a real release plan ID + string exclusionJustification = "Updated justification for exclusion."; + var updateStatus = await this.releasePlan.UpdateLanguageExclusionJustification(releasePlanWorkItemId, exclusionJustification, "Java"); + Assert.IsNotNull(updateStatus); + Assert.That(updateStatus.Message, Does.Contain("Updated language exclusion")); + + var releasePlanInfo = await this.devOpsService.GetReleasePlanForWorkItemAsync(releasePlanWorkItemId); + Assert.That(exclusionJustification, Is.EqualTo(releasePlanInfo.LanguageExclusionRequesterNote)); + } + + + [Test] // disabled by default because it makes real API calls + [Ignore("Manual test - requires real API calls")] + public async Task Test_Get_ReleaseExclusionStatus() + { + int releasePlan = 28940; // replace with a real release plan ID + var releasePlanInfo = await this.devOpsService.GetReleasePlanForWorkItemAsync(releasePlan); + Assert.IsNotNull(releasePlanInfo); + + var pythonSdk = releasePlanInfo.SDKInfo.FirstOrDefault(sdk => sdk.Language.Equals("Python", StringComparison.OrdinalIgnoreCase)); + Assert.IsNotNull(pythonSdk); + Assert.That(pythonSdk.ReleaseExclusionStatus, Is.EqualTo("Requested")); + } + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/ReleasePlanToolTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/ReleasePlanToolTests.cs index 8626e19157b..9f20f41cadc 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/ReleasePlanToolTests.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/ReleasePlanToolTests.cs @@ -229,5 +229,34 @@ public async Task Test_Update_SDK_Details_In_Release_Plan() updateStatus = await releasePlanTool.UpdateSDKDetailsInReleasePlan(100, sdkDetails); Assert.That(updateStatus.Message, Does.Contain("Updated SDK details in release plan")); } + + [Test] + public async Task Test_Update_SDK_Details_Mgmt_language_excl() + { + string sdkDetails = "[{\"language\":\".NET\",\"packageName\":\"Azure.ResourceManager.Contoso\"},{\"language\":\"JavaScript\",\"packageName\":\"@azure/arm-contoso\"}]"; + var updateStatus = await releasePlanTool.UpdateSDKDetailsInReleasePlan(100, sdkDetails); + Assert.That(updateStatus.Message, Does.Contain("Updated SDK details in release plan")); + Assert.That(updateStatus.Message, Does.Contain("Important: The following languages were excluded in the release plan. SDK must be released for all languages.")); + Assert.True(updateStatus.NextSteps?.Contains("Prompt the user for justification for excluded languages and update it in the release plan.") ?? false); + } + + + [Test] + public async Task Test_Update_SDK_Details_Data_language_excl() + { + string sdkDetails = "[{\"language\":\".NET\",\"packageName\":\"Azure.Contoso\"},{\"language\":\"JavaScript\",\"packageName\":\"@azure/contoso\"}]"; + var updateStatus = await releasePlanTool.UpdateSDKDetailsInReleasePlan(1001, sdkDetails); + Assert.That(updateStatus.Message, Does.Contain("Updated SDK details in release plan")); + Assert.That(updateStatus.Message, Does.Contain("Important: The following languages were excluded in the release plan. SDK must be released for all languages.")); + Assert.That(updateStatus.NextSteps?.Contains("Prompt the user for justification for excluded languages and update it in the release plan.") ?? false); + } + + [Test] + public async Task Test_update_language_exclusion_justification() + { + var updateStatus = await releasePlanTool.UpdateLanguageExclusionJustification(100, "This is a test justification for excluding certain languages."); + Assert.That(updateStatus.Message, Does.Contain("Updated language exclusion justification in release plan")); + } + } } diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Azure.Sdk.Tools.Cli.csproj b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Azure.Sdk.Tools.Cli.csproj index 19aad491df4..b78c05d4365 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Azure.Sdk.Tools.Cli.csproj +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Azure.Sdk.Tools.Cli.csproj @@ -11,7 +11,7 @@ ASP0000;CS8603;CS8618;CS8625;CS8604 true azsdk - 0.5.1 + 0.5.2 See CHANGELOG.md for release notes diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md index 5b3898dc7b7..78b8a5cddee 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md @@ -1,5 +1,19 @@ # Release History +## 0.5.2 (Unreleased) + +### Features + +- Added new tool to update language exclusion justification and also to mark as language as excluded from release. + +### Breaking Changes + +- None + +### Bugs Fixed + +- None + ## 0.5.1 (2025-10-07) ### Features diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Models/ReleasePlanDetails.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Models/ReleasePlanDetails.cs index 58004c7bce7..9ff2ceb71ef 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Models/ReleasePlanDetails.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Models/ReleasePlanDetails.cs @@ -33,6 +33,8 @@ public class ReleasePlanDetails public string SDKLanguages { get; set; } = string.Empty; public bool IsSpecApproved { get; set; } = false; public int ApiSpecWorkItemId { get; set; } = 0; + public string LanguageExclusionRequesterNote { get; set; } = string.Empty; + public string LanguageExclusionApproverNote { get; set; } = string.Empty; public Microsoft.VisualStudio.Services.WebApi.Patch.Json.JsonPatchDocument GetPatchDocument() { @@ -134,5 +136,8 @@ public class SDKInfo public string GenerationPipelineUrl { get; set; } = string.Empty; public string SdkPullRequestUrl { get; set; } = string.Empty; public string PackageName { get; set; } = string.Empty; + public string ReleaseStatus { get; set; } = string.Empty; + public string PullRequestStatus { get; set; } = string.Empty; + public string ReleaseExclusionStatus { get; set; } = string.Empty; } } diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/DevOpsService.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/DevOpsService.cs index 797830fb6d3..9d424d71d65 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/DevOpsService.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/DevOpsService.cs @@ -100,7 +100,7 @@ public interface IDevOpsService public Task GetPackageWorkItemAsync(string packageName, string language, string packageVersion = ""); public Task RunPipelineAsync(int pipelineDefinitionId, Dictionary templateParams, string apiSpecBranchRef = "main"); public Task>> GetPipelineLlmArtifacts(string project, int buildId); - public Task UpdateWorkItem(int workItemId, Dictionary fields); + public Task UpdateWorkItemAsync(int workItemId, Dictionary fields); } public partial class DevOpsService(ILogger logger, IDevOpsConnection connection) : IDevOpsService @@ -156,7 +156,9 @@ private async Task MapWorkItemToReleasePlanAsync(WorkItem wo IsCreatedByAgent = workItem.Fields.TryGetValue("Custom.IsCreatedByAgent", out value) && "Copilot".Equals(value?.ToString()), ReleasePlanSubmittedByEmail = workItem.Fields.TryGetValue("Custom.ReleasePlanSubmittedByEmail", out value) ? value?.ToString() ?? string.Empty : string.Empty, SDKLanguages = workItem.Fields.TryGetValue("Custom.SDKLanguages", out value) ? value?.ToString() ?? string.Empty : string.Empty, - IsSpecApproved = workItem.Fields.TryGetValue("Custom.APISpecApprovalStatus", out value) && "Approved".Equals(value?.ToString()) + IsSpecApproved = workItem.Fields.TryGetValue("Custom.APISpecApprovalStatus", out value) && "Approved".Equals(value?.ToString()), + LanguageExclusionRequesterNote = workItem.Fields.TryGetValue("Custom.ReleaseExclusionRequestNote", out value) ? value?.ToString() ?? string.Empty : string.Empty, + LanguageExclusionApproverNote = workItem.Fields.TryGetValue("Custom.ReleaseExclusionApprovalNote", out value) ? value?.ToString() ?? string.Empty : string.Empty }; var languages = new string[] { "Dotnet", "JavaScript", "Python", "Java", "Go" }; @@ -165,6 +167,9 @@ private async Task MapWorkItemToReleasePlanAsync(WorkItem wo var sdkGenPipelineUrl = workItem.Fields.TryGetValue($"Custom.SDKGenerationPipelineFor{lang}", out value) ? value?.ToString() ?? string.Empty : string.Empty; var sdkPullRequestUrl = workItem.Fields.TryGetValue($"Custom.SDKPullRequestFor{lang}", out value) ? value?.ToString() ?? string.Empty : string.Empty; var packageName = workItem.Fields.TryGetValue($"Custom.{lang}PackageName", out value) ? value?.ToString() ?? string.Empty : string.Empty; + var releaseStatus = workItem.Fields.TryGetValue($"Custom.ReleaseStatusFor{lang}", out value) ? value?.ToString() ?? string.Empty : string.Empty; + var pullRequestStatus = workItem.Fields.TryGetValue($"Custom.SDKPullRequestStatusFor{lang}", out value) ? value?.ToString() ?? string.Empty : string.Empty; + var exclusionStatus = workItem.Fields.TryGetValue($"Custom.ReleaseExclusionStatusFor{lang}", out value) ? value?.ToString() ?? string.Empty : string.Empty; if (!string.IsNullOrEmpty(sdkGenPipelineUrl) || !string.IsNullOrEmpty(sdkPullRequestUrl) || !string.IsNullOrEmpty(packageName)) { @@ -173,8 +178,10 @@ private async Task MapWorkItemToReleasePlanAsync(WorkItem wo { Language = MapLanguageIdToName(lang), GenerationPipelineUrl = sdkGenPipelineUrl, - SdkPullRequestUrl = sdkPullRequestUrl, - PackageName = packageName + SdkPullRequestUrl = sdkPullRequestUrl, + ReleaseStatus = releaseStatus, + PackageName = packageName, + ReleaseExclusionStatus = exclusionStatus } ); } @@ -280,7 +287,7 @@ public async Task CreateReleasePlanWorkItemAsync(ReleasePlanDetails re await LinkWorkItemAsChildAsync(releasePlanWorkItemId, apiSpecWorkItem.Url); // Update release plan status to in progress - releasePlanWorkItem = await UpdateWorkItem(releasePlanWorkItemId, new Dictionary + releasePlanWorkItem = await UpdateWorkItemAsync(releasePlanWorkItemId, new Dictionary { { "System.State", "In Progress" } }); @@ -391,7 +398,7 @@ private async Task LinkWorkItemAsChildAsync(int parentId, string childUrl) } } - private static string MapLanguageToId(string language) + public static string MapLanguageToId(string language) { var lang = language.ToLower(); return lang switch @@ -406,7 +413,7 @@ private static string MapLanguageToId(string language) }; } - private static string MapLanguageIdToName(string language) + public static string MapLanguageIdToName(string language) { var lang = language.ToLower(); return lang switch @@ -1131,7 +1138,7 @@ public async Task>> GetPipelineLlmArtifacts(stri return await GetLlmArtifactsAuthenticated(project, buildId); } - public async Task UpdateWorkItem(int workItemId, Dictionary fields) + public async Task UpdateWorkItemAsync(int workItemId, Dictionary fields) { var jsonLinkDocument = new Microsoft.VisualStudio.Services.WebApi.Patch.Json.JsonPatchDocument(); foreach (var item in fields) diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/ReleasePlan/ReleasePlanTool.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/ReleasePlan/ReleasePlanTool.cs index 3f0fb75f5b0..322faabfed8 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/ReleasePlan/ReleasePlanTool.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/ReleasePlan/ReleasePlanTool.cs @@ -53,10 +53,14 @@ IEnvironmentHelper environmentHelper private const string namespaceApprovalRepoOwner = "Azure"; - private readonly HashSet supportedLanguages = [ - ".NET","Java","Python","JavaScript","Go" + private readonly HashSet languagesforDataplane = [ + ".NET","Java","Python","JavaScript" ]; + private readonly HashSet languagesforMgmtplane = [ + ".NET","Java","Python","JavaScript","Go" + ]; + [GeneratedRegex("https:\\/\\/github.com\\/Azure\\/azure-sdk\\/issues\\/([0-9]+)")] private static partial Regex NameSpaceIssueUrlRegex(); @@ -342,12 +346,22 @@ public async Task UpdateSDKDetailsInReleasePlan(int rele return "Failed to deserialize SDK details."; } + // Get release plan + var releasePlan = await devOpsService.GetReleasePlanForWorkItemAsync(releasePlanWorkItemId); + if (releasePlan == null) + { + return new DefaultCommandResponse { ResponseError = $"No release plan found with work item ID {releasePlanWorkItemId}" }; + } + + var supportedLanguages = releasePlan.IsManagementPlane ? languagesforMgmtplane : languagesforDataplane; // Validate SDK language name if (SdkInfos.Any(sdk => !supportedLanguages.Contains(sdk.Language, StringComparer.OrdinalIgnoreCase))) { return $"Unsupported SDK language found. Supported languages are: {string.Join(", ", supportedLanguages)}"; } + StringBuilder sb = new(); + // Update SDK package name and languages in work item var updated = await devOpsService.UpdateReleasePlanSDKDetailsAsync(releasePlanWorkItemId, SdkInfos); if (!updated) { @@ -355,14 +369,36 @@ public async Task UpdateSDKDetailsInReleasePlan(int rele } else { - StringBuilder sb = new("Updated SDK details in release plan."); - sb.AppendLine(); + sb.Append("Updated SDK details in release plan.").AppendLine(); foreach (var sdk in SdkInfos) { sb.AppendLine($"Language: {sdk.Language}, Package name: {sdk.PackageName}"); } - return sb.ToString(); } + + // Check if any language is excluded + var excludedLanguages = supportedLanguages.Except(SdkInfos.Select(sdk => sdk.Language), StringComparer.OrdinalIgnoreCase); + if (excludedLanguages.Any()) + { + logger.LogDebug("Languages excluded in release plan. Work Item: {releasePlanWorkItemId}, languages: {excludedLanguages}", releasePlanWorkItemId, string.Join(", ", excludedLanguages)); + sb.AppendLine($"Important: The following languages were excluded in the release plan. SDK must be released for all languages. [{string.Join(", ", supportedLanguages)}]"); + sb.AppendLine("Explanation is required for any language exclusion. Please provide a justification for each excluded language."); + + // Mark excluded language as 'Requested' in the release plan work item. + Dictionary fieldsToUpdate = []; + foreach (var lang in excludedLanguages) + { + fieldsToUpdate[$"Custom.ReleaseExclusionStatusFor{DevOpsService.MapLanguageToId(lang)}"] = "Requested"; + } + await devOpsService.UpdateWorkItemAsync(releasePlanWorkItemId, fieldsToUpdate); + logger.LogDebug("Marked excluded languages as 'Requested' in release plan work item {releasePlanWorkItemId}.", releasePlanWorkItemId); + } + + return new DefaultCommandResponse + { + Message = sb.ToString(), + NextSteps = excludedLanguages.Any() ? ["Prompt the user for justification for excluded languages and update it in the release plan."] : [] + }; } catch (Exception ex) { @@ -452,5 +488,59 @@ public async Task LinkNamespaceApprovalIssue(int release }; } } + + [McpServerTool(Name = "azsdk_update_language_exclusion_justification"), Description("Update language exclusion justification in release plan work item. This tool is called to update justification for excluded languages in the release plan. " + + "Optionally pass a language name to explicitly request exclusion for a specific language.")] + public async Task UpdateLanguageExclusionJustification(int releasePlanWorkItem, string justification, string language = "") + { + try + { + if (releasePlanWorkItem <= 0) + { + return "Invalid release plan ID."; + } + if (string.IsNullOrEmpty(justification)) + { + return "No justification provided to update the release plan."; + } + + // Get release plan + var releasePlan = await devOpsService.GetReleasePlanForWorkItemAsync(releasePlanWorkItem); + if (releasePlan == null) + { + return new DefaultCommandResponse { ResponseError = $"No release plan found with work item ID {releasePlanWorkItem}" }; + } + + // Update language exclusion justification in work item + Dictionary fieldsToUpdate = new() + { + { "Custom.ReleaseExclusionRequestNote", justification } + }; + + if (!string.IsNullOrEmpty(language)) + { + fieldsToUpdate[$"Custom.ReleaseExclusionStatusFor{DevOpsService.MapLanguageToId(language)}"] = "Requested"; + } + + var updatedWorkItem = await devOpsService.UpdateWorkItemAsync(releasePlanWorkItem, fieldsToUpdate); + if (updatedWorkItem == null) + { + return new DefaultCommandResponse { ResponseError = "Failed to update the language exclusion justification in release plan." }; + } + else + { + return new DefaultCommandResponse + { + Message = "Updated language exclusion justification in release plan.", + NextSteps = [] + }; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update release plan with language exclusion justification"); + return new DefaultCommandResponse { ResponseError = $"Failed to update release plan with language exclusion justification: {ex.Message}" }; + } + } } }