Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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()
{
Expand All @@ -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")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ public void Setup()
gitHubService = new MockGitHubService();
inputSanitizer = new InputSanitizer();

var typeSpecHelperMock = new Mock<ITypeSpecHelper>();
typeSpecHelperMock.Setup(x => x.IsRepoPathForPublicSpecRepo(It.IsAny<string>())).Returns(true);
typeSpecHelper = typeSpecHelperMock.Object;

var userHelperMock = new Mock<IUserHelper>();
userHelperMock.Setup(x => x.GetUserEmail()).ReturnsAsync("test@example.com");
userHelper = userHelperMock.Object;
Expand All @@ -45,8 +41,14 @@ public void Setup()

var gitHelperMock = new Mock<IGitHelper>();
gitHelperMock.Setup(x => x.GetBranchName(It.IsAny<string>())).Returns("testBranch");
gitHelperMock.Setup(x => x.GetRepoRemoteUri(It.Is<string>(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<string>(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,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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<string, string>
{
{ "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]
Expand All @@ -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]
Expand Down
1 change: 1 addition & 0 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/TypeSpecHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ public interface ITypeSpecHelper

public string GetSpecRepoRootPath(string path);
public string GetTypeSpecProjectRelativePath(string typeSpecProjectPath);

/// <summary>
/// Checks if a string is an HTTP or HTTPS URL
/// </summary>
public bool IsUrl(string path);

/// <summary>
/// Checks if the given string is a GitHub URL pointing to a TypeSpec project in azure-rest-api-specs
/// </summary>
public bool IsValidTypeSpecProjectUrl(string url);

/// <summary>
/// Determines if a GitHub URL points to a management plane TypeSpec project
/// </summary>
public bool IsTypeSpecUrlForMgmtPlane(string url);

/// <summary>
/// Extracts the relative specification path from a GitHub URL
/// </summary>
public string GetTypeSpecProjectRelativePathFromUrl(string url);
}
public partial class TypeSpecHelper : ITypeSpecHelper
{
Expand All @@ -37,13 +57,22 @@ 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)
{
_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);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
}
}
Loading