diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Helpers/TypeSpecHelperTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Helpers/TypeSpecHelperTests.cs index 15420bb33ac..bf50bd86948 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Helpers/TypeSpecHelperTests.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Helpers/TypeSpecHelperTests.cs @@ -32,6 +32,27 @@ public void Verify_IsValidTypeSpecProject() Assert.That(result, Is.False); } + [TestCase("https://github.com/Azure/azure-rest-api-specs/blob/main/specification/dell/Dell.Storage.Management")] + [TestCase("https://github.com/Azure/azure-rest-api-specs/blob/feature-branch/specification/contoso/Contoso.Management")] + [TestCase("https://github.com/myorg/azure-rest-api-specs/blob/main/specification/test/Test.Service")] + [Test] + public void Verify_IsValidTypeSpecProjectUrl_WithUrls(string url) + { + var result = typeSpecHelper.IsValidTypeSpecProjectUrl(url); + Assert.That(result, Is.True); + } + + [TestCase("https://github.com/Azure/azure-rest-api-specs-pr/blob/main/specification/test/Test.Service")] + [TestCase("https://github.com/Azure/wrong-repo/blob/main/specification/test/Test.Service")] + [TestCase("https://example.com/specification/test/Test.Service")] + [TestCase("not-a-url")] + [Test] + public void Verify_IsValidTypeSpecProjectUrl_WithInvalidUrls(string url) + { + var result = typeSpecHelper.IsValidTypeSpecProjectUrl(url); + Assert.That(result, Is.False); + } + [Test] public void Verify_IsTypeSpecProjectForMgmtPlane() { @@ -40,6 +61,24 @@ public void Verify_IsTypeSpecProjectForMgmtPlane() Assert.That(result, Is.True); } + [TestCase("https://github.com/Azure/azure-rest-api-specs/blob/main/specification/dell/Dell.Storage.Management")] + [TestCase("https://github.com/Azure/azure-rest-api-specs/blob/main/specification/contoso/resource-manager/Contoso.Service")] + [Test] + public void Verify_IsTypeSpecUrlForMgmtPlane_WithUrls(string url) + { + var result = typeSpecHelper.IsTypeSpecUrlForMgmtPlane(url); + Assert.That(result, Is.True); + } + + [TestCase("https://github.com/Azure/azure-rest-api-specs/blob/main/specification/contoso/Contoso.DataPlane")] + [TestCase("https://github.com/Azure/azure-rest-api-specs/blob/main/specification/test/Test.Service")] + [Test] + public void Verify_IsTypeSpecUrlForMgmtPlane_WithDataPlaneUrls(string url) + { + var result = typeSpecHelper.IsTypeSpecUrlForMgmtPlane(url); + Assert.That(result, Is.False); + } + [Test] public void Test_GetSpecRepoPath() { @@ -48,6 +87,16 @@ public void Test_GetSpecRepoPath() Assert.That(result.EndsWith("TypeSpecTestData"), Is.True); } + [TestCase("https://github.com/Azure/azure-rest-api-specs/blob/main/specification/dell/Dell.Storage.Management", "specification/dell/Dell.Storage.Management")] + [TestCase("https://github.com/Azure/azure-rest-api-specs/blob/feature/specification/contoso/Contoso.Service", "specification/contoso/Contoso.Service")] + [TestCase("https://github.com/Azure/azure-rest-api-specs/blob/main/specification/test/Test.Service?query=param#L123", "specification/test/Test.Service")] + [Test] + public void Test_GetTypeSpecProjectRelativePathFromUrl(string url, string expected) + { + var result = typeSpecHelper.GetTypeSpecProjectRelativePathFromUrl(url); + Assert.That(result, Is.EqualTo(expected)); + } + [TestCase("https://github.com/Azure/azure-rest-api-specs.git")] [TestCase("https://github.com/Azure/azure-rest-api-specs")] [TestCase("https://github.com/myuser/azure-rest-api-specs.git")] diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/ReleasePlan/ReleasePlanToolTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/ReleasePlan/ReleasePlanToolTests.cs index e19d0fa8600..f577099f6ff 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/ReleasePlan/ReleasePlanToolTests.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/ReleasePlan/ReleasePlanToolTests.cs @@ -31,10 +31,6 @@ public void Setup() gitHubService = new MockGitHubService(); inputSanitizer = new InputSanitizer(); - 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; @@ -45,8 +41,14 @@ public void Setup() var gitHelperMock = new Mock(); gitHelperMock.Setup(x => x.GetBranchName(It.IsAny())).Returns("testBranch"); + gitHelperMock.Setup(x => x.GetRepoRemoteUri(It.Is(p => !string.IsNullOrEmpty(p) && !Uri.IsWellFormedUriString(p, UriKind.Absolute)))) + .Returns(new Uri("https://github.com/Azure/azure-rest-api-specs.git")); + gitHelperMock.Setup(x => x.DiscoverRepoRoot(It.Is(p => !string.IsNullOrEmpty(p) && !Uri.IsWellFormedUriString(p, UriKind.Absolute)))) + .Returns((string path) => path.Contains("specification") ? path.Substring(0, path.IndexOf("specification")) : path); gitHelper = gitHelperMock.Object; + typeSpecHelper = new TypeSpecHelper(gitHelper); + releasePlanTool = new ReleasePlanTool( devOpsService, gitHelper, @@ -108,31 +110,29 @@ public async Task Test_Create_releasePlan_with_invalid_api_version() Assert.True(releaseplan.ResponseError.Contains("Invalid API version")); } + [TestCase("TypeSpecTestData/specification/testcontoso/Contoso.Management", "July 2025", "2025-01-01", "https://github.com/Azure/azure-rest-api-specs/pull/35446", "beta")] + [TestCase("TypeSpecTestData/specification/testcontoso/Contoso.Management", "July 2025", "2025-01-01-preview", "https://github.com/Azure/azure-rest-api-specs/pull/35447", "stable")] + [TestCase("TypeSpecTestData/specification/testcontoso/Contoso.Management", "July 2025", "2025-01-01-preview", "https://github.com/Azure/azure-rest-api-specs/pull/35448", "Preview")] + [TestCase("TypeSpecTestData/specification/testcontoso/Contoso.Management", "July 2025", "2025-01-01", "https://github.com/Azure/azure-rest-api-specs/pull/35449", "GA")] + [TestCase("https://github.com/Azure/azure-rest-api-specs/blob/main/specification/dell/Dell.Storage.Management", "January 2026", "2025-03-21", "https://github.com/Azure/azure-rest-api-specs/pull/39310", "stable")] [Test] - public async Task Test_Create_releasePlan_with_valid_inputs() + public async Task Test_Create_releasePlan_with_valid_inputs(string typeSpecPath, string targetMonth, string apiVersion, string prUrl, string sdkType) { - var testCodeFilePath = "TypeSpecTestData/specification/testcontoso/Contoso.Management"; - - var releasePlanTasks = new[]{ - releasePlanTool.CreateReleasePlan(testCodeFilePath, "July 2025", "12345678-1234-5678-9012-123456789012", "12345678-1234-5678-9012-123456789012", "2025-01-01", "https://github.com/Azure/azure-rest-api-specs/pull/35446", "beta", isTestReleasePlan: true), - releasePlanTool.CreateReleasePlan(testCodeFilePath, "July 2025", "12345678-1234-5678-9012-123456789012", "12345678-1234-5678-9012-123456789012", "2025-01-01-preview", "https://github.com/Azure/azure-rest-api-specs/pull/35447", "stable", isTestReleasePlan: true), - releasePlanTool.CreateReleasePlan(testCodeFilePath, "July 2025", "12345678-1234-5678-9012-123456789012", "12345678-1234-5678-9012-123456789012", "2025-01-01-preview", "https://github.com/Azure/azure-rest-api-specs/pull/35448", "Preview", isTestReleasePlan: true), - releasePlanTool.CreateReleasePlan(testCodeFilePath, "July 2025", "12345678-1234-5678-9012-123456789012", "12345678-1234-5678-9012-123456789012", "2025-01-01", "https://github.com/Azure/azure-rest-api-specs/pull/35449", "GA", isTestReleasePlan: true), - }; - - var releasePlanResults = await Task.WhenAll(releasePlanTasks); - - var releasePlans = releasePlanResults - .Select(r => r.ReleasePlanDetails as ReleasePlanWorkItem) - .ToList(); - - foreach (var plan in releasePlans) - { - Assert.IsNotNull(plan); - Assert.IsNotNull(plan.WorkItemId); - Assert.IsNotNull(plan.ReleasePlanId); - Assert.IsNotNull(plan.ReleasePlanLink); - } + var result = await releasePlanTool.CreateReleasePlan( + typeSpecPath, + targetMonth, + "12345678-1234-5678-9012-123456789012", + "12345678-1234-5678-9012-123456789012", + apiVersion, + prUrl, + sdkType, + isTestReleasePlan: true); + + Assert.IsNull(result.ResponseError, $"Unexpected error: {result.ResponseError}"); + Assert.IsNotNull(result.ReleasePlanDetails); + Assert.IsNotNull(result.ReleasePlanDetails.WorkItemId); + Assert.IsNotNull(result.ReleasePlanDetails.ReleasePlanId); + Assert.IsNotNull(result.ReleasePlanDetails.ReleasePlanLink); } [Test] @@ -288,22 +288,15 @@ public async Task Test_Update_SDK_Details_Data_language_excl() Assert.That(updateStatus.NextSteps?.Contains("Prompt the user for justification for excluded languages and update it in the release plan.") ?? false); } + [TestCase("Javascript", "@invalid/package/name")] + [TestCase("Go", "invalid/package/name")] [Test] - public async Task Test_Update_SDK_Details_single_invalid_package_name() + public async Task Test_Update_SDK_Details_single_invalid_package_name(string language, string package) { - var languagePackageMap = new Dictionary - { - { "Javascript", "@invalid/package/name" }, - { "Go", "invalid/package/name" }, - }; - - foreach (var (language, package) in languagePackageMap) - { - string sdkDetails = $"[{{\"language\":\"{language}\",\"packageName\":\"{package}\"}}]"; - var updateStatus = await releasePlanTool.UpdateSDKDetailsInReleasePlan(100, sdkDetails); - Assert.That(updateStatus.ResponseError, Does.Contain("Unsupported package name")); - Assert.That(updateStatus.ResponseError, Does.Contain($"{language} -> {package}")); - } + string sdkDetails = $"[{{\"language\":\"{language}\",\"packageName\":\"{package}\"}}]"; + var updateStatus = await releasePlanTool.UpdateSDKDetailsInReleasePlan(100, sdkDetails); + Assert.That(updateStatus.ResponseError, Does.Contain("Unsupported package name")); + Assert.That(updateStatus.ResponseError, Does.Contain($"{language} -> {package}")); } [Test] @@ -324,28 +317,21 @@ public async Task Test_update_language_exclusion_justification() Assert.That(updateStatus.Message, Does.Contain("Updated language exclusion justification in release plan")); } + [TestCase("Python", "https://github.com/Azure/azure-sdk-for-python/pull/12345")] + [TestCase(".NET", "https://github.com/Azure/azure-sdk-for-net/pull/12345")] + [TestCase("dotnet", "https://github.com/Azure/azure-sdk-for-net/pull/12345")] + [TestCase("Dotnet", "https://github.com/Azure/azure-sdk-for-net/pull/12345")] + [TestCase("csharp", "https://github.com/Azure/azure-sdk-for-net/pull/12345")] + [TestCase("Javascript", "https://github.com/Azure/azure-sdk-for-js/pull/12345")] + [TestCase("typescript", "https://github.com/Azure/azure-sdk-for-js/pull/12345")] + [TestCase("Java", "https://github.com/Azure/azure-sdk-for-java/pull/12345")] + [TestCase("Go", "https://github.com/Azure/azure-sdk-for-go/pull/12345")] [Test] - public async Task Test_link_sdk_pull_request_to_release_plan() + public async Task Test_link_sdk_pull_request_to_release_plan(string language, string pullRequestUrl) { - var cases = new (string language, string pullRequestUrl)[] - { - ("Python", "https://github.com/Azure/azure-sdk-for-python/pull/12345"), - (".NET", "https://github.com/Azure/azure-sdk-for-net/pull/12345"), - ("dotnet", "https://github.com/Azure/azure-sdk-for-net/pull/12345"), - ("Dotnet", "https://github.com/Azure/azure-sdk-for-net/pull/12345"), - ("csharp", "https://github.com/Azure/azure-sdk-for-net/pull/12345"), - ("Javascript", "https://github.com/Azure/azure-sdk-for-js/pull/12345"), - ("typescript", "https://github.com/Azure/azure-sdk-for-js/pull/12345"), - ("Java", "https://github.com/Azure/azure-sdk-for-java/pull/12345"), - ("Go", "https://github.com/Azure/azure-sdk-for-go/pull/12345"), - }; - - foreach (var (language, pullRequestUrl) in cases) - { - var response = await releasePlanTool.LinkSdkPullRequestToReleasePlan(language, pullRequestUrl, 1, 1); - Assert.That(response.Details, Has.Some.Contains("Successfully linked pull request to release plan"), $"Assertion failed for language '{language}' and PR '{pullRequestUrl}'."); - Assert.That(response.Language, Is.Not.EqualTo(Models.SdkLanguage.Unknown), $"Language property should be set for '{language}'."); - } + var response = await releasePlanTool.LinkSdkPullRequestToReleasePlan(language, pullRequestUrl, 1, 1); + Assert.That(response.Details, Has.Some.Contains("Successfully linked pull request to release plan"), $"Assertion failed for language '{language}' and PR '{pullRequestUrl}'."); + Assert.That(response.Language, Is.Not.EqualTo(Models.SdkLanguage.Unknown), $"Language property should be set for '{language}'."); } [Test] diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md index 848b199061d..498a568b067 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md @@ -6,6 +6,7 @@ - Improved error message when GitHub authentication fails to include GitHub CLI installation and authentication instructions - Added TypeSpecProject to the telemetry data for the `azsdk_package_generate_code` tool +- Added support for GitHub URLs in TypeSpecHelper methods to accept URLs like `https://github.com/Azure/azure-rest-api-specs/blob/main/specification/...` in addition to local paths - MCP server now forwards log and subprocess output to MCP logging notifications instead of stdout ### Breaking Changes diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/TypeSpecHelper.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/TypeSpecHelper.cs index 799cb659ff0..100f38fafaa 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/TypeSpecHelper.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/TypeSpecHelper.cs @@ -28,6 +28,26 @@ public interface ITypeSpecHelper public string GetSpecRepoRootPath(string path); public string GetTypeSpecProjectRelativePath(string typeSpecProjectPath); + + /// + /// Checks if a string is an HTTP or HTTPS URL + /// + public bool IsUrl(string path); + + /// + /// Checks if the given string is a GitHub URL pointing to a TypeSpec project in azure-rest-api-specs + /// + public bool IsValidTypeSpecProjectUrl(string url); + + /// + /// Determines if a GitHub URL points to a management plane TypeSpec project + /// + public bool IsTypeSpecUrlForMgmtPlane(string url); + + /// + /// Extracts the relative specification path from a GitHub URL + /// + public string GetTypeSpecProjectRelativePathFromUrl(string url); } public partial class TypeSpecHelper : ITypeSpecHelper { @@ -37,6 +57,9 @@ public partial class TypeSpecHelper : ITypeSpecHelper [GeneratedRegex("azure-rest-api-specs{0,1}(.git){0,1}$")] private static partial Regex RestApiSpecsPublicRegex(); + [GeneratedRegex(@"^https://github\.com/[^/]+/azure-rest-api-specs/(blob|tree)/[^/]+/specification/.+$", RegexOptions.IgnoreCase)] + private static partial Regex GitHubSpecUrlRegex(); + private IGitHelper _gitHelper; public TypeSpecHelper(IGitHelper gitHelper) @@ -44,6 +67,12 @@ public TypeSpecHelper(IGitHelper gitHelper) _gitHelper = gitHelper; } + public bool IsUrl(string path) + { + return Uri.TryCreate(path, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + } + public bool IsValidTypeSpecProjectPath(string path) { return TypeSpecProject.IsValidTypeSpecProjectPath(path); @@ -77,6 +106,11 @@ public string GetSpecRepoRootPath(string path) throw new ArgumentException("path cannot be null or empty.", nameof(path)); } + if (IsUrl(path)) + { + throw new ArgumentException("GetSpecRepoRootPath does not accept URLs. Use local filesystem paths only.", nameof(path)); + } + if (Directory.Exists(Path.Combine(path, "specification"))) { return path; @@ -102,5 +136,37 @@ public string GetTypeSpecProjectRelativePath(string typeSpecProjectPath) int specIndex = typeSpecProjectPath.IndexOf("specification"); return specIndex >= 0 ? typeSpecProjectPath[specIndex..].Replace("\\", "/") : string.Empty; } + + // URL-specific helper methods + public bool IsValidTypeSpecProjectUrl(string url) + { + return IsUrl(url) && GitHubSpecUrlRegex().IsMatch(url); + } + + public bool IsTypeSpecUrlForMgmtPlane(string url) + { + if (!IsUrl(url)) + { + return false; + } + // For URLs, infer from path - check for .Management or resource-manager + return url.Contains(".Management", StringComparison.OrdinalIgnoreCase) || + url.Contains("resource-manager", StringComparison.OrdinalIgnoreCase); + } + + public string GetTypeSpecProjectRelativePathFromUrl(string url) + { + if (string.IsNullOrEmpty(url) || !IsValidTypeSpecProjectUrl(url)) + { + return string.Empty; + } + + // Parse URL to get the path component (automatically strips query params and fragments) + var uri = new Uri(url); + var path = uri.AbsolutePath; + + int specIndex = path.IndexOf("specification", StringComparison.OrdinalIgnoreCase); + return specIndex >= 0 ? path[specIndex..] : string.Empty; + } } } 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 3502bfef30d..eaf57570474 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 @@ -340,16 +340,20 @@ private void ValidateCreateReleasePlanInputAsync(string typeSpecProjectPath, str throw new Exception($"Invalid SDK release type. Supported release types are: {string.Join(", ", supportedReleaseTypes)}"); } - var repoRoot = typeSpecHelper.GetSpecRepoRootPath(typeSpecProjectPath); - - // Ensure a release plan is created only if the API specs pull request is in a public repository. - if (!typeSpecHelper.IsRepoPathForPublicSpecRepo(repoRoot)) + // Skip filesystem validation for URLs since GetSpecRepoRootPath expects local paths + if (!typeSpecHelper.IsUrl(typeSpecProjectPath)) { - throw new Exception(""" - SDK generation and release require the API specs pull request to be in the public azure-rest-api-specs repository. - Please create a pull request in the public Azure/azure-rest-api-specs repository to move your specs changes to public. - A release plan cannot be created for SDK generation using a pull request in a private repository. - """); + var repoRoot = typeSpecHelper.GetSpecRepoRootPath(typeSpecProjectPath); + + // Ensure a release plan is created only if the API specs pull request is in a public repository. + if (!typeSpecHelper.IsRepoPathForPublicSpecRepo(repoRoot)) + { + throw new Exception(""" + SDK generation and release require the API specs pull request to be in the public azure-rest-api-specs repository. + Please create a pull request in the public Azure/azure-rest-api-specs repository to move your specs changes to public. + A release plan cannot be created for SDK generation using a pull request in a private repository. + """); + } } if (!Guid.TryParse(serviceTreeId, out _)) @@ -418,9 +422,27 @@ public async Task CreateReleasePlan(string typeSpecProjectP } } - var specType = typeSpecHelper.IsValidTypeSpecProjectPath(typeSpecProjectPath) ? "TypeSpec" : "OpenAPI"; - var isMgmt = typeSpecHelper.IsTypeSpecProjectForMgmtPlane(typeSpecProjectPath); - var specProject = typeSpecHelper.GetTypeSpecProjectRelativePath(typeSpecProjectPath); + // Handle both URLs and local paths for TypeSpec projects + bool isValidTypeSpec; + bool isMgmt; + string specProject; + + if (typeSpecHelper.IsUrl(typeSpecProjectPath)) + { + // URL path + isValidTypeSpec = typeSpecHelper.IsValidTypeSpecProjectUrl(typeSpecProjectPath); + isMgmt = typeSpecHelper.IsTypeSpecUrlForMgmtPlane(typeSpecProjectPath); + specProject = typeSpecHelper.GetTypeSpecProjectRelativePathFromUrl(typeSpecProjectPath); + } + else + { + // Local file path + isValidTypeSpec = typeSpecHelper.IsValidTypeSpecProjectPath(typeSpecProjectPath); + isMgmt = typeSpecHelper.IsTypeSpecProjectForMgmtPlane(typeSpecProjectPath); + specProject = typeSpecHelper.GetTypeSpecProjectRelativePath(typeSpecProjectPath); + } + + var specType = isValidTypeSpec ? "TypeSpec" : "OpenAPI"; logger.LogInformation("Attempting to retrieve current user email."); var email = await userHelper.GetUserEmail();