From 4a93d2755021fc762d59a9563405c0cf506b00ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 15:00:30 +0000 Subject: [PATCH 1/6] Initial plan From fe950530dc995b23406fc01ac9c90c4b6c1702e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 15:05:30 +0000 Subject: [PATCH 2/6] Fix JobMonitor resubmit source metadata fields --- .../JobMonitor/Services/HelixService.cs | 57 ++++++++++++++++++- .../HelixServiceTests.cs | 55 +++++++++++++++++- 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs b/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs index 22e0cadde92..c65638a0b95 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 (TryParseSource(details.Source, 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,49 @@ private static ImmutableDictionary ConvertPropertiesToImmutableD return builder.ToImmutable(); } + private static bool TryParseSource( + string source, + out string sourcePrefix, + out string teamProject, + out string repository, + out string branch) + { + sourcePrefix = null; + teamProject = null; + repository = null; + branch = null; + + if (string.IsNullOrWhiteSpace(source)) + { + return false; + } + + int firstSlash = source.IndexOf('/'); + if (firstSlash <= 0 || firstSlash >= source.Length - 1) + { + return false; + } + + int secondSlash = source.IndexOf('/', firstSlash + 1); + if (secondSlash <= firstSlash + 1 || secondSlash >= source.Length - 1) + { + return false; + } + + string remainder = source[(secondSlash + 1)..]; + int refsStart = remainder.IndexOf("/refs/", StringComparison.Ordinal); + if (refsStart <= 0 || refsStart >= remainder.Length - 1) + { + return false; + } + + sourcePrefix = source[..firstSlash]; + teamProject = source[(firstSlash + 1)..secondSlash]; + repository = remainder[..refsStart]; + branch = remainder[(refsStart + 1)..]; + return true; + } + 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..6d573def722 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,51 @@ 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")); + 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); + } + private static HelixService CreateService(IHelixApi api, IBlobClientFactory blobClientFactory = null, IFileSystem fileSystem = null) => new( api, @@ -283,8 +332,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 = "ci/public/dotnet/arcade/refs/heads/main") + => new("https://storage/job-list.json", null, "original-job", "wait", source, "helix-type", "build") { Creator = "creator", QueueId = "queue-id", From 0ac825f99b75ecdf5de8166b401cf2a2b0527ac9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:30:26 +0000 Subject: [PATCH 3/6] Use job properties for resubmission source metadata --- .../JobMonitor/Services/HelixService.cs | 20 ++++++++++++++++++- .../HelixServiceTests.cs | 8 ++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs b/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs index c65638a0b95..12d4c60c288 100644 --- a/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs +++ b/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs @@ -280,7 +280,8 @@ await RetryHelper.RetryAsync( Properties = ConvertPropertiesToImmutableDictionary(details.Properties) .SetItem(HelixJobInfo.PreviousHelixJobNamePropertyName, originalJobName), }; - if (TryParseSource(details.Source, out string sourcePrefix, out string teamProject, out string repository, out string branch)) + if (TryGetSourceMetadataFromProperties(details.Properties, out string sourcePrefix, out string teamProject, out string repository, out string branch) + || TryParseSource(details.Source, out sourcePrefix, out teamProject, out repository, out branch)) { creationRequest.SourcePrefix = sourcePrefix; creationRequest.TeamProject = teamProject; @@ -345,6 +346,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 bool TryParseSource( string source, out string sourcePrefix, 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 6d573def722..9e44333002f 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 @@ -267,7 +267,7 @@ public async Task ResubmitWorkItemsAsync_FallsBackToSourceWhenSourceCannotBePars var api = CreateApi(); api.Job .Setup(j => j.DetailsAsync("original-job", It.IsAny())) - .ReturnsAsync(JobDetails(source: "source")); + .ReturnsAsync(JobDetails(source: "source", includeSourceMetadataInProperties: false)); api.Storage .Setup(s => s.NewAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new ContainerInformation( @@ -332,7 +332,7 @@ private static JobSummary Job(string name, string finished, JToken properties) Properties = properties, }; - private static JobDetails JobDetails(string source = "ci/public/dotnet/arcade/refs/heads/main") + 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", @@ -342,6 +342,10 @@ private static JobDetails JobDetails(string source = "ci/public/dotnet/arcade/re ["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, From e04b7cb141255808189f5aaec089e0fcd125a851 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:32:43 +0000 Subject: [PATCH 4/6] Add explicit source-metadata fallback coverage --- .../HelixServiceTests.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) 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 9e44333002f..2bb484369e1 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 @@ -306,6 +306,51 @@ await CreateService(api.Api.Object, blobClientFactory) Assert.Null(capturedRequest.Branch); } + [Fact] + public async Task ResubmitWorkItemsAsync_FallsBackToParsingSourceWhenPropertiesAreMissing() + { + 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.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); + } + private static HelixService CreateService(IHelixApi api, IBlobClientFactory blobClientFactory = null, IFileSystem fileSystem = null) => new( api, From caa8529bcedd739cce7058429fe76945792582d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:42:56 +0000 Subject: [PATCH 5/6] Remove source parsing fallback for JobMonitor resubmission --- .../JobMonitor/Services/HelixService.cs | 46 +------------------ .../HelixServiceTests.cs | 12 ++--- 2 files changed, 7 insertions(+), 51 deletions(-) diff --git a/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs b/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs index 12d4c60c288..5570783807a 100644 --- a/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs +++ b/src/Microsoft.DotNet.Helix/JobMonitor/Services/HelixService.cs @@ -280,8 +280,7 @@ await RetryHelper.RetryAsync( Properties = ConvertPropertiesToImmutableDictionary(details.Properties) .SetItem(HelixJobInfo.PreviousHelixJobNamePropertyName, originalJobName), }; - if (TryGetSourceMetadataFromProperties(details.Properties, out string sourcePrefix, out string teamProject, out string repository, out string branch) - || TryParseSource(details.Source, out sourcePrefix, out teamProject, out repository, out branch)) + if (TryGetSourceMetadataFromProperties(details.Properties, out string sourcePrefix, out string teamProject, out string repository, out string branch)) { creationRequest.SourcePrefix = sourcePrefix; creationRequest.TeamProject = teamProject; @@ -363,49 +362,6 @@ private static bool TryGetSourceMetadataFromProperties( && !string.IsNullOrWhiteSpace(branch); } - private static bool TryParseSource( - string source, - out string sourcePrefix, - out string teamProject, - out string repository, - out string branch) - { - sourcePrefix = null; - teamProject = null; - repository = null; - branch = null; - - if (string.IsNullOrWhiteSpace(source)) - { - return false; - } - - int firstSlash = source.IndexOf('/'); - if (firstSlash <= 0 || firstSlash >= source.Length - 1) - { - return false; - } - - int secondSlash = source.IndexOf('/', firstSlash + 1); - if (secondSlash <= firstSlash + 1 || secondSlash >= source.Length - 1) - { - return false; - } - - string remainder = source[(secondSlash + 1)..]; - int refsStart = remainder.IndexOf("/refs/", StringComparison.Ordinal); - if (refsStart <= 0 || refsStart >= remainder.Length - 1) - { - return false; - } - - sourcePrefix = source[..firstSlash]; - teamProject = source[(firstSlash + 1)..secondSlash]; - repository = remainder[..refsStart]; - branch = remainder[(refsStart + 1)..]; - return true; - } - 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 2bb484369e1..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 @@ -307,7 +307,7 @@ await CreateService(api.Api.Object, blobClientFactory) } [Fact] - public async Task ResubmitWorkItemsAsync_FallsBackToParsingSourceWhenPropertiesAreMissing() + public async Task ResubmitWorkItemsAsync_FallsBackToSourceWhenSourceMetadataPropertiesAreMissing() { var api = CreateApi(); api.Job @@ -344,11 +344,11 @@ await CreateService(api.Api.Object, blobClientFactory) .ResubmitWorkItemsAsync(new HelixJobInfo("original-job", "finished"), [WorkItem("work-a")], CancellationToken.None); Assert.NotNull(capturedRequest); - 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("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) From 2cb576bd517117cef9766f6797d4d0767dc6e7b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:32:15 +0000 Subject: [PATCH 6/6] Align JobMonitorRunner tests with AwesomeAssertions --- .../Microsoft.DotNet.Helix.Sdk.Tests/JobMonitorRunnerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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(); } });