diff --git a/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs b/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs index 22e0cadde92..5570783807a 100644 --- a/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs +++ b/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs @@ -272,15 +272,25 @@ await RetryHelper.RetryAsync( string newJobListUri = AppendSasIfPresent(jobListBlobClient.Uri, container.ReadToken); - // 5. Build the new job creation request, copying over Source / Properties / Creator + // 5. Build the new job creation request, copying over source metadata / properties / creator // so the resubmitted job remains discoverable (BuildId, System.StageName, TestRunName, etc.). var creationRequest = new JobCreationRequest(details.Type, newJobListUri, details.QueueId) { - Source = details.Source, Creator = details.Creator, Properties = ConvertPropertiesToImmutableDictionary(details.Properties) .SetItem(HelixJobInfo.PreviousHelixJobNamePropertyName, originalJobName), }; + if (TryGetSourceMetadataFromProperties(details.Properties, out string sourcePrefix, out string teamProject, out string repository, out string branch)) + { + creationRequest.SourcePrefix = sourcePrefix; + creationRequest.TeamProject = teamProject; + creationRequest.Repository = repository; + creationRequest.Branch = branch; + } + else + { + creationRequest.Source = details.Source; + } string idempotencyKey = Guid.NewGuid().ToString("N"); JobCreationResult newJob = await RetryHelper.RetryAsync( @@ -335,6 +345,23 @@ private static ImmutableDictionary ConvertPropertiesToImmutableD return builder.ToImmutable(); } + private static bool TryGetSourceMetadataFromProperties( + JToken properties, + out string sourcePrefix, + out string teamProject, + out string repository, + out string branch) + { + sourcePrefix = GetStringPropertyFromProperties(properties, "SourcePrefix"); + teamProject = GetStringPropertyFromProperties(properties, "TeamProject"); + repository = GetStringPropertyFromProperties(properties, "Repository"); + branch = GetStringPropertyFromProperties(properties, "Branch"); + return !string.IsNullOrWhiteSpace(sourcePrefix) + && !string.IsNullOrWhiteSpace(teamProject) + && !string.IsNullOrWhiteSpace(repository) + && !string.IsNullOrWhiteSpace(branch); + } + private static string GetStringPropertyFromProperties(JToken properties, string name) { if (properties is JObject obj && obj.TryGetValue(name, out JToken token)) diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixServiceTests.cs b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixServiceTests.cs index 2659481b390..258c76fee3f 100644 --- a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixServiceTests.cs +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixServiceTests.cs @@ -243,7 +243,11 @@ public async Task ResubmitWorkItemsAsync_UploadsFilteredJobListAndCreatesJobWith Assert.Equal("helix-type", capturedRequest.Type); Assert.Equal("queue-id", capturedRequest.QueueId); Assert.Equal("https://account.blob.core.windows.net/container/job-list.json?read", capturedRequest.ListUri); - Assert.Equal("source", capturedRequest.Source); + Assert.Null(capturedRequest.Source); + Assert.Equal("ci", capturedRequest.SourcePrefix); + Assert.Equal("public", capturedRequest.TeamProject); + Assert.Equal("dotnet/arcade", capturedRequest.Repository); + Assert.Equal("refs/heads/main", capturedRequest.Branch); Assert.Equal("creator", capturedRequest.Creator); Assert.Equal("123", capturedRequest.Properties["BuildId"]); Assert.Equal("custom run", capturedRequest.Properties["TestRunName"]); @@ -257,6 +261,96 @@ public async Task ResubmitWorkItemsAsync_UploadsFilteredJobListAndCreatesJobWith Times.Once); } + [Fact] + public async Task ResubmitWorkItemsAsync_FallsBackToSourceWhenSourceCannotBeParsed() + { + var api = CreateApi(); + api.Job + .Setup(j => j.DetailsAsync("original-job", It.IsAny())) + .ReturnsAsync(JobDetails(source: "source", includeSourceMetadataInProperties: false)); + api.Storage + .Setup(s => s.NewAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ContainerInformation( + DateTimeOffset.Parse("2026-04-30T00:00:00Z"), + DateTimeOffset.Parse("2026-05-30T00:00:00Z"), + "creator", + "container", + "account", + Guid.Empty, + "region") + { + ReadToken = "?read", + WriteToken = "?write", + }); + + JobCreationRequest capturedRequest = null; + api.Job + .Setup(j => j.NewAsync(It.IsAny(), It.IsAny(), null, It.IsAny())) + .Callback((request, _, _, _) => capturedRequest = request) + .ReturnsAsync(new JobCreationResult("new-job", "summary", "results", null)); + + var blobClientFactory = new FakeBlobClientFactory + { + DownloadedText = """[{ "WorkItemId": "work-a", "Command": "run a", "PayloadUri": "payload-a" }]""", + UploadedBlobUri = new Uri("https://account.blob.core.windows.net/container/job-list.json"), + }; + + await CreateService(api.Api.Object, blobClientFactory) + .ResubmitWorkItemsAsync(new HelixJobInfo("original-job", "finished"), [WorkItem("work-a")], CancellationToken.None); + + Assert.NotNull(capturedRequest); + Assert.Equal("source", capturedRequest.Source); + Assert.Null(capturedRequest.SourcePrefix); + Assert.Null(capturedRequest.TeamProject); + Assert.Null(capturedRequest.Repository); + Assert.Null(capturedRequest.Branch); + } + + [Fact] + public async Task ResubmitWorkItemsAsync_FallsBackToSourceWhenSourceMetadataPropertiesAreMissing() + { + var api = CreateApi(); + api.Job + .Setup(j => j.DetailsAsync("original-job", It.IsAny())) + .ReturnsAsync(JobDetails(source: "ci/public/dotnet/arcade/refs/heads/main", includeSourceMetadataInProperties: false)); + api.Storage + .Setup(s => s.NewAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ContainerInformation( + DateTimeOffset.Parse("2026-04-30T00:00:00Z"), + DateTimeOffset.Parse("2026-05-30T00:00:00Z"), + "creator", + "container", + "account", + Guid.Empty, + "region") + { + ReadToken = "?read", + WriteToken = "?write", + }); + + JobCreationRequest capturedRequest = null; + api.Job + .Setup(j => j.NewAsync(It.IsAny(), It.IsAny(), null, It.IsAny())) + .Callback((request, _, _, _) => capturedRequest = request) + .ReturnsAsync(new JobCreationResult("new-job", "summary", "results", null)); + + var blobClientFactory = new FakeBlobClientFactory + { + DownloadedText = """[{ "WorkItemId": "work-a", "Command": "run a", "PayloadUri": "payload-a" }]""", + UploadedBlobUri = new Uri("https://account.blob.core.windows.net/container/job-list.json"), + }; + + await CreateService(api.Api.Object, blobClientFactory) + .ResubmitWorkItemsAsync(new HelixJobInfo("original-job", "finished"), [WorkItem("work-a")], CancellationToken.None); + + Assert.NotNull(capturedRequest); + Assert.Equal("ci/public/dotnet/arcade/refs/heads/main", capturedRequest.Source); + Assert.Null(capturedRequest.SourcePrefix); + Assert.Null(capturedRequest.TeamProject); + Assert.Null(capturedRequest.Repository); + Assert.Null(capturedRequest.Branch); + } + private static HelixService CreateService(IHelixApi api, IBlobClientFactory blobClientFactory = null, IFileSystem fileSystem = null) => new( api, @@ -283,8 +377,8 @@ private static JobSummary Job(string name, string finished, JToken properties) Properties = properties, }; - private static JobDetails JobDetails() - => new("https://storage/job-list.json", null, "original-job", "wait", "source", "helix-type", "build") + private static JobDetails JobDetails(string source = "source", bool includeSourceMetadataInProperties = true) + => new("https://storage/job-list.json", null, "original-job", "wait", source, "helix-type", "build") { Creator = "creator", QueueId = "queue-id", @@ -293,6 +387,10 @@ private static JobDetails JobDetails() ["BuildId"] = "123", ["TestRunName"] = "custom run", ["System.StageName"] = "test stage", + ["SourcePrefix"] = includeSourceMetadataInProperties ? "ci" : null, + ["TeamProject"] = includeSourceMetadataInProperties ? "public" : null, + ["Repository"] = includeSourceMetadataInProperties ? "dotnet/arcade" : null, + ["Branch"] = includeSourceMetadataInProperties ? "refs/heads/main" : null, ["ObjectProperty"] = new JObject { ["nested"] = true, diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/JobMonitorRunnerTests.cs b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/JobMonitorRunnerTests.cs index e9235264bb8..d493fc1ddba 100644 --- a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/JobMonitorRunnerTests.cs +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/JobMonitorRunnerTests.cs @@ -1179,7 +1179,7 @@ public async Task MonitorTimesOut_Relaunched_UploadsRemainingResults() if (pollCount1 >= 2) { Task completed = await Task.WhenAny(azdo1.UploadCompleted.Task, Task.Delay(TimeSpan.FromSeconds(5))); - Assert.Same(azdo1.UploadCompleted.Task, completed); + completed.Should().BeSameAs(azdo1.UploadCompleted.Task); cts.Cancel(); } }); @@ -1384,7 +1384,7 @@ public async Task MonitorTimesOut_PartialProgress_Relaunched_CompletesSuccessful if (pollCount1 >= 2) { Task completed = await Task.WhenAny(azdo1.UploadCompleted.Task, Task.Delay(TimeSpan.FromSeconds(5))); - Assert.Same(azdo1.UploadCompleted.Task, completed); + completed.Should().BeSameAs(azdo1.UploadCompleted.Task); cts.Cancel(); } });