Skip to content

Commit 572ed6c

Browse files
Copilotmitchdenny
andauthored
Filter to highest package version per channel in aspire add command (#12553)
* Initial plan * Filter package versions to show only highest per channel Co-authored-by: mitchdenny <[email protected]> * Add tests to validate package version filtering Co-authored-by: mitchdenny <[email protected]> * Address code review comments with documentation and type safety improvements Co-authored-by: mitchdenny <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: mitchdenny <[email protected]>
1 parent 48011da commit 572ed6c

File tree

3 files changed

+207
-15
lines changed

3 files changed

+207
-15
lines changed

src/Aspire.Cli/Commands/AddCommand.cs

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -300,39 +300,43 @@ static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, Pac
300300
return selection.Result;
301301
}
302302

303-
// Group the incoming package versions by channel
303+
// Group the incoming package versions by channel and filter to highest version per channel
304304
var byChannel = packages
305305
.GroupBy(p => p.Channel)
306+
.Select(g => new
307+
{
308+
Channel = g.Key,
309+
// Keep only the highest version in each channel
310+
HighestVersion = g.OrderByDescending(p => SemVersion.Parse(p.Package.Version), SemVersion.PrecedenceComparer).First()
311+
})
306312
.ToArray();
307313

308-
var implicitGroup = byChannel.FirstOrDefault(g => g.Key.Type is Packaging.PackageChannelType.Implicit);
314+
var implicitGroup = byChannel.FirstOrDefault(g => g.Channel.Type is Packaging.PackageChannelType.Implicit);
309315
var explicitGroups = byChannel
310-
.Where(g => g.Key.Type is Packaging.PackageChannelType.Explicit)
316+
.Where(g => g.Channel.Type is Packaging.PackageChannelType.Explicit)
311317
.ToArray();
312318

313319
// Build the root menu: implicit channel packages directly, explicit channels as submenus
314320
var rootChoices = new List<(string Label, Func<CancellationToken, Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>> Action)>();
315321

316322
if (implicitGroup is not null)
317323
{
318-
foreach (var item in implicitGroup)
319-
{
320-
var captured = item;
321-
rootChoices.Add((
322-
Label: FormatVersionLabel(captured),
323-
Action: ct => Task.FromResult(captured)
324-
));
325-
}
324+
var captured = implicitGroup.HighestVersion;
325+
rootChoices.Add((
326+
Label: FormatVersionLabel(captured),
327+
Action: ct => Task.FromResult(captured)
328+
));
326329
}
327330

328331
foreach (var channelGroup in explicitGroups)
329332
{
330-
var channel = channelGroup.Key;
331-
var items = channelGroup.ToArray();
333+
var channel = channelGroup.Channel;
334+
var item = channelGroup.HighestVersion;
332335

333336
rootChoices.Add((
334337
Label: channel.Name,
335-
Action: ct => PromptForChannelPackagesAsync(channel, items, ct)
338+
// For explicit channels, we still show submenu but with only the highest version
339+
Action: ct => PromptForChannelPackagesAsync(channel, new[] { item }, ct)
336340
));
337341
}
338342

@@ -353,9 +357,15 @@ static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, Pac
353357

354358
public virtual async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForIntegrationAsync(IEnumerable<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> packages, CancellationToken cancellationToken)
355359
{
360+
// Filter to show only the highest version for each package ID
361+
var filteredPackages = packages
362+
.GroupBy(p => p.Package.Id)
363+
.Select(g => g.OrderByDescending(p => SemVersion.Parse(p.Package.Version), SemVersion.PrecedenceComparer).First())
364+
.ToArray();
365+
356366
var selectedIntegration = await interactionService.PromptForSelectionAsync(
357367
AddCommandStrings.SelectAnIntegrationToAdd,
358-
packages,
368+
filteredPackages,
359369
PackageNameWithFriendlyNameIfAvailable,
360370
cancellationToken);
361371
return selectedIntegration;

tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,170 @@ public void GenerateFriendlyName_ProducesExpectedResults(string packageId, strin
538538
Assert.Equal(expectedFriendlyName, result.FriendlyName);
539539
Assert.Equal(package, result.Package);
540540
}
541+
542+
[Fact]
543+
public async Task AddCommandPrompter_FiltersToHighestVersionPerPackageId()
544+
{
545+
// Arrange
546+
List<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>? displayedPackages = null;
547+
548+
using var workspace = TemporaryWorkspace.Create(outputHelper);
549+
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
550+
{
551+
options.InteractionServiceFactory = (sp) =>
552+
{
553+
var mockInteraction = new TestConsoleInteractionService();
554+
mockInteraction.PromptForSelectionCallback = (message, choices, formatter, ct) =>
555+
{
556+
// Capture what the prompter passes to the interaction service
557+
var choicesList = choices.Cast<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)>().ToList();
558+
displayedPackages = choicesList;
559+
return choicesList.First();
560+
};
561+
return mockInteraction;
562+
};
563+
});
564+
var provider = services.BuildServiceProvider();
565+
var interactionService = provider.GetRequiredService<IInteractionService>();
566+
567+
var prompter = new AddCommandPrompter(interactionService);
568+
569+
// Create a fake channel
570+
var fakeCache = new FakeNuGetPackageCache();
571+
var channel = PackageChannel.CreateImplicitChannel(fakeCache);
572+
573+
// Create multiple versions of the same package
574+
var packages = new[]
575+
{
576+
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.0.0", Source = "nuget" }, channel),
577+
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.2.0", Source = "nuget" }, channel),
578+
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.1.0", Source = "nuget" }, channel),
579+
};
580+
581+
// Act
582+
await prompter.PromptForIntegrationAsync(packages, CancellationToken.None);
583+
584+
// Assert - should only show highest version (9.2.0) for the package ID
585+
Assert.NotNull(displayedPackages);
586+
Assert.Single(displayedPackages!);
587+
Assert.Equal("9.2.0", displayedPackages!.First().Package.Version);
588+
}
589+
590+
[Fact]
591+
public async Task AddCommandPrompter_FiltersToHighestVersionPerChannel()
592+
{
593+
// Arrange
594+
List<object>? displayedChoices = null;
595+
596+
using var workspace = TemporaryWorkspace.Create(outputHelper);
597+
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
598+
{
599+
options.InteractionServiceFactory = (sp) =>
600+
{
601+
var mockInteraction = new TestConsoleInteractionService();
602+
mockInteraction.PromptForSelectionCallback = (message, choices, formatter, ct) =>
603+
{
604+
// Capture what the prompter passes to the interaction service
605+
var choicesList = choices.Cast<object>().ToList();
606+
displayedChoices = choicesList;
607+
return choicesList.First();
608+
};
609+
return mockInteraction;
610+
};
611+
});
612+
var provider = services.BuildServiceProvider();
613+
var interactionService = provider.GetRequiredService<IInteractionService>();
614+
615+
var prompter = new AddCommandPrompter(interactionService);
616+
617+
// Create a fake channel
618+
var fakeCache = new FakeNuGetPackageCache();
619+
var channel = PackageChannel.CreateImplicitChannel(fakeCache);
620+
621+
// Create multiple versions of the same package from same channel
622+
var packages = new[]
623+
{
624+
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.0.0", Source = "nuget" }, channel),
625+
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.2.0", Source = "nuget" }, channel),
626+
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.1.0", Source = "nuget" }, channel),
627+
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.0.1-preview.1", Source = "nuget" }, channel),
628+
};
629+
630+
// Act
631+
await prompter.PromptForIntegrationVersionAsync(packages, CancellationToken.None);
632+
633+
// Assert - For implicit channel, should only show highest version (9.2.0) directly
634+
// The root menu shows: (string Label, Func<...> Action) tuples
635+
Assert.NotNull(displayedChoices);
636+
Assert.Single(displayedChoices!); // Only one choice for implicit channel
637+
}
638+
639+
[Fact]
640+
public async Task AddCommandPrompter_ShowsHighestVersionPerChannelWhenMultipleChannels()
641+
{
642+
// Arrange
643+
List<object>? displayedChoices = null;
644+
645+
using var workspace = TemporaryWorkspace.Create(outputHelper);
646+
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
647+
{
648+
options.InteractionServiceFactory = (sp) =>
649+
{
650+
var mockInteraction = new TestConsoleInteractionService();
651+
mockInteraction.PromptForSelectionCallback = (message, choices, formatter, ct) =>
652+
{
653+
// Capture what the prompter passes to the interaction service
654+
var choicesList = choices.Cast<object>().ToList();
655+
displayedChoices = choicesList;
656+
return choicesList.First();
657+
};
658+
return mockInteraction;
659+
};
660+
});
661+
var provider = services.BuildServiceProvider();
662+
var interactionService = provider.GetRequiredService<IInteractionService>();
663+
664+
var prompter = new AddCommandPrompter(interactionService);
665+
666+
// Create two different channels
667+
var fakeCache = new FakeNuGetPackageCache();
668+
var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache);
669+
670+
var mappings = new[] { new PackageMapping("Aspire*", "https://preview-feed") };
671+
var explicitChannel = PackageChannel.CreateExplicitChannel("preview", PackageChannelQuality.Prerelease, mappings, fakeCache);
672+
673+
// Create packages from different channels with different versions
674+
var packages = new[]
675+
{
676+
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.0.0", Source = "nuget" }, implicitChannel),
677+
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.1.0", Source = "nuget" }, implicitChannel),
678+
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "9.2.0", Source = "nuget" }, implicitChannel),
679+
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "10.0.0-preview.1", Source = "preview-feed" }, explicitChannel),
680+
("redis", new NuGetPackage { Id = "Aspire.Hosting.Redis", Version = "10.0.0-preview.2", Source = "preview-feed" }, explicitChannel),
681+
};
682+
683+
// Act
684+
await prompter.PromptForIntegrationVersionAsync(packages, CancellationToken.None);
685+
686+
// Assert - should show 2 root choices: one for implicit channel, one submenu for explicit channel
687+
Assert.NotNull(displayedChoices);
688+
Assert.Equal(2, displayedChoices!.Count);
689+
}
690+
691+
private sealed class FakeNuGetPackageCache : Aspire.Cli.NuGet.INuGetPackageCache
692+
{
693+
public Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
694+
=> Task.FromResult<IEnumerable<NuGetPackage>>([]);
695+
696+
public Task<IEnumerable<NuGetPackage>> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
697+
=> Task.FromResult<IEnumerable<NuGetPackage>>([]);
698+
699+
public Task<IEnumerable<NuGetPackage>> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
700+
=> Task.FromResult<IEnumerable<NuGetPackage>>([]);
701+
702+
public Task<IEnumerable<NuGetPackage>> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func<string, bool>? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken)
703+
=> Task.FromResult<IEnumerable<NuGetPackage>>([]);
704+
}
541705
}
542706

543707
internal sealed class TestAddCommandPrompter(IInteractionService interactionService) : AddCommandPrompter(interactionService)

tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Collections;
45
using Aspire.Cli.Backchannel;
56
using Aspire.Cli.Interaction;
67
using Spectre.Console;
@@ -14,6 +15,13 @@ internal sealed class TestConsoleInteractionService : IInteractionService
1415
public Action<string>? DisplayConsoleWriteLineMessage { get; set; }
1516
public Func<string, bool, bool>? ConfirmCallback { get; set; }
1617
public Action<string>? ShowStatusCallback { get; set; }
18+
19+
/// <summary>
20+
/// Callback for capturing selection prompts in tests. Uses non-generic IEnumerable and object
21+
/// to work with the generic PromptForSelectionAsync&lt;T&gt; method regardless of T's type.
22+
/// This allows tests to inspect what choices are presented without knowing the generic type at compile time.
23+
/// </summary>
24+
public Func<string, IEnumerable, Func<object, string>, CancellationToken, object>? PromptForSelectionCallback { get; set; }
1725

1826
public Task<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> action)
1927
{
@@ -38,6 +46,16 @@ public Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T> choi
3846
throw new EmptyChoicesException($"No items available for selection: {promptText}");
3947
}
4048

49+
if (PromptForSelectionCallback is not null)
50+
{
51+
// Invoke the callback - casting is safe here because:
52+
// 1. 'choices' is IEnumerable<T>, and we cast items to T when calling choiceFormatter
53+
// 2. 'result' comes from the callback which receives 'choices', so it must be of type T
54+
// 3. These casts are for test infrastructure only, not production code
55+
var result = PromptForSelectionCallback(promptText, choices, o => choiceFormatter((T)o), cancellationToken);
56+
return Task.FromResult((T)result);
57+
}
58+
4159
return Task.FromResult(choices.First());
4260
}
4361

0 commit comments

Comments
 (0)