Skip to content

Commit f52bb19

Browse files
smw-msCopilot
authored andcommitted
Add automation to report on non-released SDKs (Azure#13333)
* Add automation to report on non-released SDKs * Respond to copilot comments * Move to release plan tool * fix release plan tests * Update Changelog * Update Email Template * Remove trailing whitespace Co-authored-by: Copilot <[email protected]> * Use ReleasePlanWorkItem instead of ReleasePlanDetails --------- Co-authored-by: Copilot <[email protected]>
1 parent 4f38a08 commit f52bb19

File tree

6 files changed

+367
-24
lines changed

6 files changed

+367
-24
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
trigger: none
2+
pr: none
3+
4+
variables:
5+
- template: /eng/pipelines/templates/variables/image.yml
6+
7+
pool:
8+
name: $(LINUXPOOL)
9+
demands: ImageOverride -equals $(LINUXVMIMAGE)
10+
11+
jobs:
12+
- job: ReportUnreleasedSdks
13+
steps:
14+
- checkout: self
15+
16+
- task: PowerShell@2
17+
displayName: 'Install Azure SDK MCP'
18+
inputs:
19+
targetType: 'inline'
20+
script: './eng/common/mcp/azure-sdk-mcp.ps1 -InstallDirectory $(System.DefaultWorkingDirectory)'
21+
pwsh: true
22+
workingDirectory: '$(System.DefaultWorkingDirectory)'
23+
24+
- task: AzureCLI@2
25+
displayName: Email product owners about overdue SDK release plans
26+
inputs:
27+
azureSubscription: opensource-api-connection
28+
scriptType: pscore
29+
scriptLocation: inlineScript
30+
inlineScript: |
31+
& "$(System.DefaultWorkingDirectory)/azsdk" release-plan list-overdue --notify-owners true --emailer-uri "$(AzureSDKEmailerSasURL)"

tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/ReleasePlan/ReleasePlanManualTests.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ internal class ReleasePlanManualTests
2626
private IEnvironmentHelper environmentHelper;
2727
private readonly IGitHelper gitHelper;
2828
private IInputSanitizer inputSanitizer;
29+
private HttpClient httpClient;
2930

3031
public ReleasePlanManualTests()
3132
{
@@ -37,6 +38,7 @@ public ReleasePlanManualTests()
3738
logger = new TestLogger<ReleasePlanTool>();
3839
gitHubService = new Mock<IGitHubService>().Object;
3940
inputSanitizer = new InputSanitizer();
41+
httpClient = new Mock<HttpClient>().Object;
4042

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

57-
releasePlan = new ReleasePlanTool(devOpsService, gitHelper, typeSpecHelper, logger, userHelper, gitHubService, environmentHelper, inputSanitizer);
59+
releasePlan = new ReleasePlanTool(devOpsService, gitHelper, typeSpecHelper, logger, userHelper, gitHubService, environmentHelper, inputSanitizer, httpClient);
5860
}
5961

6062
[Test] // disabled by default because it makes real API calls

tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/ReleasePlan/ReleasePlanToolTests.cs

Lines changed: 204 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Moq;
2+
using Moq.Protected;
23
using Azure.Sdk.Tools.Cli.Helpers;
34
using Azure.Sdk.Tools.Cli.Services;
45
using Azure.Sdk.Tools.Cli.Tests.Mocks.Services;
@@ -21,6 +22,7 @@ internal class ReleasePlanToolTests
2122
private IEnvironmentHelper environmentHelper;
2223
private ReleasePlanTool releasePlanTool;
2324
private IInputSanitizer inputSanitizer;
25+
private HttpClient httpClient;
2426

2527
[SetUp]
2628
public void Setup()
@@ -30,6 +32,7 @@ public void Setup()
3032
devOpsService = new MockDevOpsService();
3133
gitHubService = new MockGitHubService();
3234
inputSanitizer = new InputSanitizer();
35+
httpClient = new Mock<HttpClient>().Object;
3336

3437
var userHelperMock = new Mock<IUserHelper>();
3538
userHelperMock.Setup(x => x.GetUserEmail()).ReturnsAsync("[email protected]");
@@ -57,7 +60,8 @@ public void Setup()
5760
userHelper,
5861
gitHubService,
5962
environmentHelper,
60-
inputSanitizer);
63+
inputSanitizer,
64+
httpClient);
6165
}
6266

6367
[Test]
@@ -150,7 +154,8 @@ public async Task Test_Create_releasePlan_with_AZSDKTOOLS_AGENT_TESTING_true_cre
150154
userHelper,
151155
gitHubService,
152156
environmentHelperMock.Object,
153-
inputSanitizer);
157+
inputSanitizer,
158+
httpClient);
154159

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

@@ -191,7 +196,8 @@ public async Task Test_Create_releasePlan_with_AZSDKTOOLS_AGENT_TESTING_false_re
191196
userHelper,
192197
gitHubService,
193198
environmentHelperMock.Object,
194-
inputSanitizer);
199+
inputSanitizer,
200+
httpClient);
195201

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

@@ -419,5 +425,200 @@ public async Task Test_update_spec_pull_request_with_non_specs_repo()
419425
Assert.That(response.ResponseError, Does.Contain("Invalid spec pull request URL"));
420426
Assert.That(response.ResponseError, Does.Contain("azure-rest-api-specs"));
421427
}
428+
429+
[Test]
430+
public async Task Test_list_overdue_release_plans_notify_without_emailer_uri()
431+
{
432+
var response = await releasePlanTool.ListOverdueReleasePlans(notifyOwners: true, emailerUri: "");
433+
Assert.IsNotNull(response);
434+
Assert.IsNotNull(response.ResponseError);
435+
Assert.That(response.ResponseError, Does.Contain("Emailer URI is required"));
436+
}
437+
438+
[Test]
439+
public async Task Test_notification_includes_correct_missing_sdks()
440+
{
441+
var mockDevOps = new Mock<IDevOpsService>();
442+
var plan = new ReleasePlanWorkItem
443+
{
444+
WorkItemId = 200,
445+
Owner = "Test Owner",
446+
ReleasePlanSubmittedByEmail = "[email protected]",
447+
IsManagementPlane = true,
448+
IsDataPlane = false,
449+
SDKReleaseMonth = "January 2026",
450+
ReleasePlanLink = "https://example.com/releaseplan/200",
451+
SDKInfo =
452+
[
453+
new SDKInfo { Language = "Java", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" },
454+
new SDKInfo { Language = "Python", ReleaseStatus = "Released", ReleaseExclusionStatus = "Not applicable" },
455+
new SDKInfo { Language = ".NET", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" }
456+
]
457+
};
458+
mockDevOps.Setup(x => x.ListOverdueReleasePlansAsync()).ReturnsAsync([plan]);
459+
460+
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
461+
var capturedBody = "";
462+
mockHttpMessageHandler.Protected()
463+
.Setup<Task<HttpResponseMessage>>(
464+
"SendAsync",
465+
ItExpr.IsAny<HttpRequestMessage>(),
466+
ItExpr.IsAny<CancellationToken>())
467+
.ReturnsAsync((HttpRequestMessage request, CancellationToken token) =>
468+
{
469+
var content = request.Content?.ReadAsStringAsync().Result ?? "";
470+
var payload = JsonSerializer.Deserialize<JsonElement>(content);
471+
capturedBody = payload.GetProperty("Body").GetString() ?? "";
472+
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
473+
});
474+
475+
var testHttpClient = new HttpClient(mockHttpMessageHandler.Object);
476+
var tool = new ReleasePlanTool(mockDevOps.Object, gitHelper, typeSpecHelper, logger, userHelper, gitHubService, environmentHelper, inputSanitizer, testHttpClient);
477+
478+
await tool.ListOverdueReleasePlans(notifyOwners: true, emailerUri: "https://test.com/email");
479+
480+
Assert.That(capturedBody, Does.Contain("Java"));
481+
Assert.That(capturedBody, Does.Contain(".NET"));
482+
Assert.That(capturedBody, Does.Not.Contain("Python")); // Released, should not be in missing list
483+
}
484+
485+
[Test]
486+
public async Task Test_notification_excludes_approved_and_requested_languages()
487+
{
488+
var mockDevOps = new Mock<IDevOpsService>();
489+
var plan = new ReleasePlanWorkItem
490+
{
491+
WorkItemId = 201,
492+
Owner = "Test Owner",
493+
ReleasePlanSubmittedByEmail = "[email protected]",
494+
IsManagementPlane = true,
495+
IsDataPlane = false,
496+
SDKReleaseMonth = "January 2026",
497+
ReleasePlanLink = "https://example.com/releaseplan/201",
498+
SDKInfo =
499+
[
500+
new SDKInfo { Language = "Java", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" },
501+
new SDKInfo { Language = "Python", ReleaseStatus = "", ReleaseExclusionStatus = "Approved" },
502+
new SDKInfo { Language = ".NET", ReleaseStatus = "", ReleaseExclusionStatus = "Requested" }
503+
]
504+
};
505+
mockDevOps.Setup(x => x.ListOverdueReleasePlansAsync()).ReturnsAsync([plan]);
506+
507+
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
508+
var capturedBody = "";
509+
mockHttpMessageHandler.Protected()
510+
.Setup<Task<HttpResponseMessage>>(
511+
"SendAsync",
512+
ItExpr.IsAny<HttpRequestMessage>(),
513+
ItExpr.IsAny<CancellationToken>())
514+
.ReturnsAsync((HttpRequestMessage request, CancellationToken token) =>
515+
{
516+
var content = request.Content?.ReadAsStringAsync().Result ?? "";
517+
var payload = JsonSerializer.Deserialize<JsonElement>(content);
518+
capturedBody = payload.GetProperty("Body").GetString() ?? "";
519+
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
520+
});
521+
522+
var testHttpClient = new HttpClient(mockHttpMessageHandler.Object);
523+
var tool = new ReleasePlanTool(mockDevOps.Object, gitHelper, typeSpecHelper, logger, userHelper, gitHubService, environmentHelper, inputSanitizer, testHttpClient);
524+
525+
await tool.ListOverdueReleasePlans(notifyOwners: true, emailerUri: "https://test.com/email");
526+
527+
Assert.That(capturedBody, Does.Contain("Java"));
528+
Assert.That(capturedBody, Does.Not.Contain("Python")); // Approved exclusion
529+
Assert.That(capturedBody, Does.Not.Contain(".NET")); // Requested exclusion
530+
}
531+
532+
[Test]
533+
public async Task Test_notification_excludes_go_for_dataplane()
534+
{
535+
var mockDevOps = new Mock<IDevOpsService>();
536+
var plan = new ReleasePlanWorkItem
537+
{
538+
WorkItemId = 202,
539+
Owner = "Test Owner",
540+
ReleasePlanSubmittedByEmail = "[email protected]",
541+
IsDataPlane = true,
542+
IsManagementPlane = false,
543+
SDKReleaseMonth = "January 2026",
544+
ReleasePlanLink = "https://example.com/releaseplan/202",
545+
SDKInfo =
546+
[
547+
new SDKInfo { Language = "Java", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" },
548+
new SDKInfo { Language = "Go", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" }
549+
]
550+
};
551+
mockDevOps.Setup(x => x.ListOverdueReleasePlansAsync()).ReturnsAsync([plan]);
552+
553+
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
554+
var capturedBody = "";
555+
mockHttpMessageHandler.Protected()
556+
.Setup<Task<HttpResponseMessage>>(
557+
"SendAsync",
558+
ItExpr.IsAny<HttpRequestMessage>(),
559+
ItExpr.IsAny<CancellationToken>())
560+
.ReturnsAsync((HttpRequestMessage request, CancellationToken token) =>
561+
{
562+
var content = request.Content?.ReadAsStringAsync().Result ?? "";
563+
var payload = JsonSerializer.Deserialize<JsonElement>(content);
564+
capturedBody = payload.GetProperty("Body").GetString() ?? "";
565+
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
566+
});
567+
568+
var testHttpClient = new HttpClient(mockHttpMessageHandler.Object);
569+
var tool = new ReleasePlanTool(mockDevOps.Object, gitHelper, typeSpecHelper, logger, userHelper, gitHubService, environmentHelper, inputSanitizer, testHttpClient);
570+
571+
await tool.ListOverdueReleasePlans(notifyOwners: true, emailerUri: "https://test.com/email");
572+
573+
Assert.That(capturedBody, Does.Contain("Java"));
574+
Assert.That(capturedBody, Does.Not.Contain("Go")); // Filtered for Data Plane
575+
Assert.That(capturedBody, Does.Contain("Data Plane"));
576+
}
577+
578+
[Test]
579+
public async Task Test_notification_includes_go_for_management_plane()
580+
{
581+
var mockDevOps = new Mock<IDevOpsService>();
582+
var plan = new ReleasePlanWorkItem
583+
{
584+
WorkItemId = 203,
585+
Owner = "Test Owner",
586+
ReleasePlanSubmittedByEmail = "[email protected]",
587+
IsManagementPlane = true,
588+
IsDataPlane = false,
589+
SDKReleaseMonth = "January 2026",
590+
ReleasePlanLink = "https://example.com/releaseplan/203",
591+
SDKInfo =
592+
[
593+
new SDKInfo { Language = "Java", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" },
594+
new SDKInfo { Language = "Go", ReleaseStatus = "", ReleaseExclusionStatus = "Not applicable" }
595+
]
596+
};
597+
mockDevOps.Setup(x => x.ListOverdueReleasePlansAsync()).ReturnsAsync([plan]);
598+
599+
var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
600+
var capturedBody = "";
601+
mockHttpMessageHandler.Protected()
602+
.Setup<Task<HttpResponseMessage>>(
603+
"SendAsync",
604+
ItExpr.IsAny<HttpRequestMessage>(),
605+
ItExpr.IsAny<CancellationToken>())
606+
.ReturnsAsync((HttpRequestMessage request, CancellationToken token) =>
607+
{
608+
var content = request.Content?.ReadAsStringAsync().Result ?? "";
609+
var payload = JsonSerializer.Deserialize<JsonElement>(content);
610+
capturedBody = payload.GetProperty("Body").GetString() ?? "";
611+
return new HttpResponseMessage(System.Net.HttpStatusCode.OK);
612+
});
613+
614+
var testHttpClient = new HttpClient(mockHttpMessageHandler.Object);
615+
var tool = new ReleasePlanTool(mockDevOps.Object, gitHelper, typeSpecHelper, logger, userHelper, gitHubService, environmentHelper, inputSanitizer, testHttpClient);
616+
617+
await tool.ListOverdueReleasePlans(notifyOwners: true, emailerUri: "https://test.com/email");
618+
619+
Assert.That(capturedBody, Does.Contain("Java"));
620+
Assert.That(capturedBody, Does.Contain("Go")); // Included for Management Plane
621+
Assert.That(capturedBody, Does.Contain("Management Plane"));
622+
}
422623
}
423624
}

tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Improved error message when GitHub authentication fails to include GitHub CLI installation and authentication instructions
88
- Added TypeSpecProject to the telemetry data for the `azsdk_package_generate_code` tool
9+
- Added email notification support for overdue release plan owners.
910
- 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
1011
- MCP server now forwards log and subprocess output to MCP logging notifications instead of stdout
1112
- Added `APISpecProjectPath` property to Release Plan Work Item to track the TypeSpec project path in release plans

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/DevOpsService.cs

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -254,22 +254,19 @@ private async Task<ReleasePlanWorkItem> MapWorkItemToReleasePlanAsync(WorkItem w
254254
var pullRequestStatus = workItem.Fields.TryGetValue($"Custom.SDKPullRequestStatusFor{lang}", out value) ? value?.ToString() ?? string.Empty : string.Empty;
255255
var exclusionStatus = workItem.Fields.TryGetValue($"Custom.ReleaseExclusionStatusFor{lang}", out value) ? value?.ToString() ?? string.Empty : string.Empty;
256256

257-
if (!string.IsNullOrEmpty(sdkGenPipelineUrl) || !string.IsNullOrEmpty(sdkPullRequestUrl) || !string.IsNullOrEmpty(packageName))
258-
{
259-
releasePlan.SDKInfo.Add(
260-
new SDKInfo()
261-
{
262-
Language = MapLanguageIdToName(lang),
263-
GenerationPipelineUrl = sdkGenPipelineUrl,
264-
SdkPullRequestUrl = sdkPullRequestUrl,
265-
GenerationStatus = generationStatus,
266-
ReleaseStatus = releaseStatus,
267-
PullRequestStatus = pullRequestStatus,
268-
PackageName = packageName,
269-
ReleaseExclusionStatus = exclusionStatus
270-
}
271-
);
272-
}
257+
releasePlan.SDKInfo.Add(
258+
new SDKInfo()
259+
{
260+
Language = MapLanguageIdToName(lang),
261+
GenerationPipelineUrl = sdkGenPipelineUrl,
262+
SdkPullRequestUrl = sdkPullRequestUrl,
263+
GenerationStatus = generationStatus,
264+
ReleaseStatus = releaseStatus,
265+
PullRequestStatus = pullRequestStatus,
266+
PackageName = packageName,
267+
ReleaseExclusionStatus = exclusionStatus
268+
}
269+
);
273270
}
274271

275272
// Get details from API spec work item

0 commit comments

Comments
 (0)