Skip to content
Merged
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("test@example.com");
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 = "valid@example.com",
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 = "valid@example.com",
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 = "valid@example.com",
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 = "valid@example.com",
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