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
31 changes: 31 additions & 0 deletions eng/pipelines/report-unreleased-sdks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
trigger: none
pr: none

variables:
- template: /eng/pipelines/templates/variables/image.yml

pool:
name: $(LINUXPOOL)
demands: ImageOverride -equals $(LINUXVMIMAGE)

jobs:
- job: ReportUnreleasedSdks
steps:
- checkout: self

- task: PowerShell@2
displayName: 'Install Azure SDK MCP'
inputs:
targetType: 'inline'
script: './eng/common/mcp/azure-sdk-mcp.ps1 -InstallDirectory $(System.DefaultWorkingDirectory)'
pwsh: true
workingDirectory: '$(System.DefaultWorkingDirectory)'

- task: AzureCLI@2
displayName: Email product owners about overdue SDK release plans
inputs:
azureSubscription: opensource-api-connection
scriptType: pscore
scriptLocation: inlineScript
inlineScript: |
& "$(System.DefaultWorkingDirectory)/azsdk" release-plan list-overdue --notify-owners true --emailer-uri "$(AzureSDKEmailerSasURL)"
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal class ReleasePlanManualTests
private IEnvironmentHelper environmentHelper;
private readonly IGitHelper gitHelper;
private IInputSanitizer inputSanitizer;
private HttpClient httpClient;

public ReleasePlanManualTests()
{
Expand All @@ -37,6 +38,7 @@ public ReleasePlanManualTests()
logger = new TestLogger<ReleasePlanTool>();
gitHubService = new Mock<IGitHubService>().Object;
inputSanitizer = new InputSanitizer();
httpClient = new Mock<HttpClient>().Object;

var typeSpecHelperMock = new Mock<ITypeSpecHelper>();
typeSpecHelperMock.Setup(x => x.IsRepoPathForPublicSpecRepo(It.IsAny<string>())).Returns(true);
Expand All @@ -54,7 +56,7 @@ public ReleasePlanManualTests()
gitHelperMock.Setup(x => x.GetBranchName(It.IsAny<string>())).Returns("testBranch");
gitHelper = gitHelperMock.Object;

releasePlan = new ReleasePlanTool(devOpsService, gitHelper, typeSpecHelper, logger, userHelper, gitHubService, environmentHelper, inputSanitizer);
releasePlan = new ReleasePlanTool(devOpsService, gitHelper, typeSpecHelper, logger, userHelper, gitHubService, environmentHelper, inputSanitizer, httpClient);
}

[Test] // disabled by default because it makes real API calls
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Moq;
using Moq.Protected;
using Azure.Sdk.Tools.Cli.Helpers;
using Azure.Sdk.Tools.Cli.Services;
using Azure.Sdk.Tools.Cli.Tests.Mocks.Services;
Expand All @@ -21,6 +22,7 @@ internal class ReleasePlanToolTests
private IEnvironmentHelper environmentHelper;
private ReleasePlanTool releasePlanTool;
private IInputSanitizer inputSanitizer;
private HttpClient httpClient;

[SetUp]
public void Setup()
Expand All @@ -30,6 +32,7 @@ public void Setup()
devOpsService = new MockDevOpsService();
gitHubService = new MockGitHubService();
inputSanitizer = new InputSanitizer();
httpClient = new Mock<HttpClient>().Object;

var userHelperMock = new Mock<IUserHelper>();
userHelperMock.Setup(x => x.GetUserEmail()).ReturnsAsync("[email protected]");
Expand Down Expand Up @@ -57,7 +60,8 @@ public void Setup()
userHelper,
gitHubService,
environmentHelper,
inputSanitizer);
inputSanitizer,
httpClient);
}

[Test]
Expand Down Expand Up @@ -150,7 +154,8 @@ public async Task Test_Create_releasePlan_with_AZSDKTOOLS_AGENT_TESTING_true_cre
userHelper,
gitHubService,
environmentHelperMock.Object,
inputSanitizer);
inputSanitizer,
httpClient);

var testCodeFilePath = "TypeSpecTestData/specification/testcontoso/Contoso.Management";

Expand Down Expand Up @@ -191,7 +196,8 @@ public async Task Test_Create_releasePlan_with_AZSDKTOOLS_AGENT_TESTING_false_re
userHelper,
gitHubService,
environmentHelperMock.Object,
inputSanitizer);
inputSanitizer,
httpClient);

var testCodeFilePath = "TypeSpecTestData/specification/testcontoso/Contoso.Management";

Expand Down Expand Up @@ -419,5 +425,200 @@ public async Task Test_update_spec_pull_request_with_non_specs_repo()
Assert.That(response.ResponseError, Does.Contain("Invalid spec pull request URL"));
Assert.That(response.ResponseError, Does.Contain("azure-rest-api-specs"));
}

[Test]
public async Task Test_list_overdue_release_plans_notify_without_emailer_uri()
{
var response = await releasePlanTool.ListOverdueReleasePlans(notifyOwners: true, emailerUri: "");
Assert.IsNotNull(response);
Assert.IsNotNull(response.ResponseError);
Assert.That(response.ResponseError, Does.Contain("Emailer URI is required"));
}

[Test]
public async Task Test_notification_includes_correct_missing_sdks()
{
var mockDevOps = new Mock<IDevOpsService>();
var plan = new ReleasePlanWorkItem
{
WorkItemId = 200,
Owner = "Test Owner",
ReleasePlanSubmittedByEmail = "[email protected]",
IsManagementPlane = true,
IsDataPlane = false,
SDKReleaseMonth = "January 2026",
ReleasePlanLink = "https://example.com/releaseplan/200",
SDKInfo =
[
new SDKInfo { Language = "Java", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" },
new SDKInfo { Language = "Python", ReleaseStatus = "Released", ReleaseExclusionStatus = "Not applicable" },
new SDKInfo { Language = ".NET", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" }
]
};
mockDevOps.Setup(x => x.ListOverdueReleasePlansAsync()).ReturnsAsync([plan]);

var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
var capturedBody = "";
mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync((HttpRequestMessage request, CancellationToken token) =>
{
var content = request.Content?.ReadAsStringAsync().Result ?? "";
var payload = JsonSerializer.Deserialize<JsonElement>(content);
capturedBody = payload.GetProperty("Body").GetString() ?? "";
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
});

var testHttpClient = new HttpClient(mockHttpMessageHandler.Object);
var tool = new ReleasePlanTool(mockDevOps.Object, gitHelper, typeSpecHelper, logger, userHelper, gitHubService, environmentHelper, inputSanitizer, testHttpClient);

await tool.ListOverdueReleasePlans(notifyOwners: true, emailerUri: "https://test.com/email");

Assert.That(capturedBody, Does.Contain("Java"));
Assert.That(capturedBody, Does.Contain(".NET"));
Assert.That(capturedBody, Does.Not.Contain("Python")); // Released, should not be in missing list
}

[Test]
public async Task Test_notification_excludes_approved_and_requested_languages()
{
var mockDevOps = new Mock<IDevOpsService>();
var plan = new ReleasePlanWorkItem
{
WorkItemId = 201,
Owner = "Test Owner",
ReleasePlanSubmittedByEmail = "[email protected]",
IsManagementPlane = true,
IsDataPlane = false,
SDKReleaseMonth = "January 2026",
ReleasePlanLink = "https://example.com/releaseplan/201",
SDKInfo =
[
new SDKInfo { Language = "Java", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" },
new SDKInfo { Language = "Python", ReleaseStatus = "", ReleaseExclusionStatus = "Approved" },
new SDKInfo { Language = ".NET", ReleaseStatus = "", ReleaseExclusionStatus = "Requested" }
]
};
mockDevOps.Setup(x => x.ListOverdueReleasePlansAsync()).ReturnsAsync([plan]);

var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
var capturedBody = "";
mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync((HttpRequestMessage request, CancellationToken token) =>
{
var content = request.Content?.ReadAsStringAsync().Result ?? "";
var payload = JsonSerializer.Deserialize<JsonElement>(content);
capturedBody = payload.GetProperty("Body").GetString() ?? "";
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
});

var testHttpClient = new HttpClient(mockHttpMessageHandler.Object);
var tool = new ReleasePlanTool(mockDevOps.Object, gitHelper, typeSpecHelper, logger, userHelper, gitHubService, environmentHelper, inputSanitizer, testHttpClient);

await tool.ListOverdueReleasePlans(notifyOwners: true, emailerUri: "https://test.com/email");

Assert.That(capturedBody, Does.Contain("Java"));
Assert.That(capturedBody, Does.Not.Contain("Python")); // Approved exclusion
Assert.That(capturedBody, Does.Not.Contain(".NET")); // Requested exclusion
}

[Test]
public async Task Test_notification_excludes_go_for_dataplane()
{
var mockDevOps = new Mock<IDevOpsService>();
var plan = new ReleasePlanWorkItem
{
WorkItemId = 202,
Owner = "Test Owner",
ReleasePlanSubmittedByEmail = "[email protected]",
IsDataPlane = true,
IsManagementPlane = false,
SDKReleaseMonth = "January 2026",
ReleasePlanLink = "https://example.com/releaseplan/202",
SDKInfo =
[
new SDKInfo { Language = "Java", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" },
new SDKInfo { Language = "Go", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" }
]
};
mockDevOps.Setup(x => x.ListOverdueReleasePlansAsync()).ReturnsAsync([plan]);

var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
var capturedBody = "";
mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync((HttpRequestMessage request, CancellationToken token) =>
{
var content = request.Content?.ReadAsStringAsync().Result ?? "";
var payload = JsonSerializer.Deserialize<JsonElement>(content);
capturedBody = payload.GetProperty("Body").GetString() ?? "";
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
});

var testHttpClient = new HttpClient(mockHttpMessageHandler.Object);
var tool = new ReleasePlanTool(mockDevOps.Object, gitHelper, typeSpecHelper, logger, userHelper, gitHubService, environmentHelper, inputSanitizer, testHttpClient);

await tool.ListOverdueReleasePlans(notifyOwners: true, emailerUri: "https://test.com/email");

Assert.That(capturedBody, Does.Contain("Java"));
Assert.That(capturedBody, Does.Not.Contain("Go")); // Filtered for Data Plane
Assert.That(capturedBody, Does.Contain("Data Plane"));
}

[Test]
public async Task Test_notification_includes_go_for_management_plane()
{
var mockDevOps = new Mock<IDevOpsService>();
var plan = new ReleasePlanWorkItem
{
WorkItemId = 203,
Owner = "Test Owner",
ReleasePlanSubmittedByEmail = "[email protected]",
IsManagementPlane = true,
IsDataPlane = false,
SDKReleaseMonth = "January 2026",
ReleasePlanLink = "https://example.com/releaseplan/203",
SDKInfo =
[
new SDKInfo { Language = "Java", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" },
new SDKInfo { Language = "Go", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" }
]
};
mockDevOps.Setup(x => x.ListOverdueReleasePlansAsync()).ReturnsAsync([plan]);

var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
var capturedBody = "";
mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync((HttpRequestMessage request, CancellationToken token) =>
{
var content = request.Content?.ReadAsStringAsync().Result ?? "";
var payload = JsonSerializer.Deserialize<JsonElement>(content);
capturedBody = payload.GetProperty("Body").GetString() ?? "";
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
});

var testHttpClient = new HttpClient(mockHttpMessageHandler.Object);
var tool = new ReleasePlanTool(mockDevOps.Object, gitHelper, typeSpecHelper, logger, userHelper, gitHubService, environmentHelper, inputSanitizer, testHttpClient);

await tool.ListOverdueReleasePlans(notifyOwners: true, emailerUri: "https://test.com/email");

Assert.That(capturedBody, Does.Contain("Java"));
Assert.That(capturedBody, Does.Contain("Go")); // Included for Management Plane
Assert.That(capturedBody, Does.Contain("Management Plane"));
}
}
}
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 email notification support for overdue release plan owners.
- 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
- Added `APISpecProjectPath` property to Release Plan Work Item to track the TypeSpec project path in release plans
Expand Down
29 changes: 13 additions & 16 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/DevOpsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,22 +254,19 @@ private async Task<ReleasePlanWorkItem> MapWorkItemToReleasePlanAsync(WorkItem w
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))
{
releasePlan.SDKInfo.Add(
new SDKInfo()
{
Language = MapLanguageIdToName(lang),
GenerationPipelineUrl = sdkGenPipelineUrl,
SdkPullRequestUrl = sdkPullRequestUrl,
GenerationStatus = generationStatus,
ReleaseStatus = releaseStatus,
PullRequestStatus = pullRequestStatus,
PackageName = packageName,
ReleaseExclusionStatus = exclusionStatus
}
);
}
releasePlan.SDKInfo.Add(
new SDKInfo()
{
Language = MapLanguageIdToName(lang),
GenerationPipelineUrl = sdkGenPipelineUrl,
SdkPullRequestUrl = sdkPullRequestUrl,
GenerationStatus = generationStatus,
ReleaseStatus = releaseStatus,
PullRequestStatus = pullRequestStatus,
PackageName = packageName,
ReleaseExclusionStatus = exclusionStatus
}
);
}

// Get details from API spec work item
Expand Down
Loading