From e3abda5b9ddcefdf6ea0266e14f46a0db3b3eb65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:24:01 +0000 Subject: [PATCH 1/8] Add Azure portal link for Resource Group in pipeline summary When printing the Resource Group in the pipeline summary of `aspire deploy`, include a clickable link to the Azure portal resource group page. The link uses the format: https://portal.azure.com/#@{tenantId}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview Changes: - AzureEnvironmentResource.AddToPipelineSummary: construct markdown link for resource group - ConsoleActivityLogger.FormatPipelineSummaryKvp: convert markdown to Spectre markup for clickable links - Add ConsoleActivityLoggerTests for the new markdown rendering behavior Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- src/Aspire.Cli/Utils/ConsoleActivityLogger.cs | 14 ++- .../AzureEnvironmentResource.cs | 13 ++- .../Utils/ConsoleActivityLoggerTests.cs | 103 ++++++++++++++++++ 3 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs diff --git a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs index 5a723f60c2a..78521083498 100644 --- a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs +++ b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs @@ -15,7 +15,7 @@ namespace Aspire.Cli.Utils; /// rewriting the entire existing publishing pipeline. Integrates by mapping publish /// step/task events to Start/Progress/Success/Warning/Failure calls. /// -internal sealed class ConsoleActivityLogger +internal sealed partial class ConsoleActivityLogger { private readonly IAnsiConsole _console; private readonly bool _enableColor; @@ -304,21 +304,27 @@ public void WriteSummary() /// /// Formats a single key-value pair for the pipeline summary display. + /// Values may contain markdown links which are converted to clickable links when supported. /// private string FormatPipelineSummaryKvp(string key, string value) { if (_enableColor) { var escapedKey = key.EscapeMarkup(); - var escapedValue = value.EscapeMarkup(); - return $" [blue]{escapedKey}[/]: {escapedValue}"; + var convertedValue = MarkdownToSpectreConverter.ConvertToSpectre(value); + convertedValue = HighlightMessage(convertedValue); + return $" [blue]{escapedKey}[/]: {convertedValue}"; } else { - return $" {key}: {value}"; + var plainValue = MarkdownLinkToPlainTextRegex().Replace(value, "$1 ($2)"); + return $" {key}: {plainValue}"; } } + [GeneratedRegex(@"\[([^\]]+)\]\(([^)]+)\)")] + private static partial Regex MarkdownLinkToPlainTextRegex(); + /// /// Sets the final pipeline result lines to be displayed in the summary (e.g., PIPELINE FAILED ...). /// Optional usage so existing callers remain compatible. diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 19a62a9e3e5..70271568b88 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -139,9 +139,20 @@ private static void AddToPipelineSummary(PipelineStepContext ctx, ProvisioningCo var subscriptionId = provisioningContext.Subscription?.Id.Name ?? "unknown"; var location = provisioningContext.Location.Name; + // Build resource group value with a portal link when possible + var resourceGroupValue = resourceGroupName; + if (resourceGroupName != "unknown" && subscriptionId != "unknown") + { + var tenantId = provisioningContext.Tenant?.TenantId; + var portalUrl = tenantId.HasValue + ? $"https://portal.azure.com/#@{tenantId.Value}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview" + : $"https://portal.azure.com/#/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview"; + resourceGroupValue = $"{resourceGroupName} ([link]({portalUrl}))"; + } + #pragma warning disable ASPIREPIPELINES001 // PipelineSummary is experimental ctx.Summary.Add("☁️ Target", "Azure"); - ctx.Summary.Add("📦 Resource Group", resourceGroupName); + ctx.Summary.Add("📦 Resource Group", resourceGroupValue); ctx.Summary.Add("🔑 Subscription", subscriptionId); ctx.Summary.Add("🌐 Location", location); #pragma warning restore ASPIREPIPELINES001 diff --git a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs new file mode 100644 index 00000000000..20015aaf440 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Aspire.Cli.Utils; +using Spectre.Console; + +namespace Aspire.Cli.Tests.Utils; + +public class ConsoleActivityLoggerTests +{ + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_RendersClickableLink() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var summary = new List> + { + new("☁️ Target", "Azure"), + new("📦 Resource Group", "VNetTest5 ([link](https://portal.azure.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview))"), + new("🔑 Subscription", "sub-id"), + new("🌐 Location", "eastus"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // Verify the markdown link was converted to a Spectre link + Assert.Contains("VNetTest5", result); + Assert.Contains("link", result); + Assert.Contains("portal.azure.com", result); + } + + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_NoColor_RendersPlainTextWithUrl() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: false); + + var portalUrl = "https://portal.azure.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview"; + var summary = new List> + { + new("📦 Resource Group", $"VNetTest5 ([link]({portalUrl}))"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // When color is disabled, markdown links should be converted to plain text: text (url) + Assert.Contains("VNetTest5", result); + Assert.Contains(portalUrl, result); + } + + [Fact] + public void WriteSummary_WithPlainTextPipelineSummary_RendersCorrectly() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var summary = new List> + { + new("☁️ Target", "Azure"), + new("🌐 Location", "eastus"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + Assert.Contains("Azure", result); + Assert.Contains("eastus", result); + } +} From efb3bbda9b320c1cac735c34cbc06919402c33fa Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 13 Feb 2026 17:22:14 -0600 Subject: [PATCH 2/8] Clean up the code --- .../AzureEnvironmentResource.cs | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 70271568b88..7325e690d4b 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -133,29 +133,19 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet /// The Azure provisioning context. private static void AddToPipelineSummary(PipelineStepContext ctx, ProvisioningContext provisioningContext) { - // Safely access the nested properties with null checks for reference types - // AzureLocation is a struct so it cannot be null - var resourceGroupName = provisioningContext.ResourceGroup?.Name ?? "unknown"; - var subscriptionId = provisioningContext.Subscription?.Id.Name ?? "unknown"; + var resourceGroupName = provisioningContext.ResourceGroup.Name; + var subscriptionId = provisioningContext.Subscription.Id.Name; var location = provisioningContext.Location.Name; - // Build resource group value with a portal link when possible - var resourceGroupValue = resourceGroupName; - if (resourceGroupName != "unknown" && subscriptionId != "unknown") - { - var tenantId = provisioningContext.Tenant?.TenantId; - var portalUrl = tenantId.HasValue - ? $"https://portal.azure.com/#@{tenantId.Value}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview" - : $"https://portal.azure.com/#/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview"; - resourceGroupValue = $"{resourceGroupName} ([link]({portalUrl}))"; - } + var tenantId = provisioningContext.Tenant.TenantId; + var tenantSegment = tenantId.HasValue ? $"#@{tenantId.Value}" : "#"; + var portalUrl = $"https://portal.azure.com/{tenantSegment}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview"; + var resourceGroupValue = $"[{resourceGroupName}]({portalUrl})"; -#pragma warning disable ASPIREPIPELINES001 // PipelineSummary is experimental ctx.Summary.Add("☁️ Target", "Azure"); ctx.Summary.Add("📦 Resource Group", resourceGroupValue); ctx.Summary.Add("🔑 Subscription", subscriptionId); ctx.Summary.Add("🌐 Location", location); -#pragma warning restore ASPIREPIPELINES001 } private Task PublishAsync(PipelineStepContext context) From a708fa697412f1516423b790a5f543a69846c818 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 17 Feb 2026 11:33:44 -0600 Subject: [PATCH 3/8] Fix tests --- .../Utils/ConsoleActivityLoggerTests.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs index 20015aaf440..8268fe43078 100644 --- a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs @@ -38,8 +38,12 @@ public void WriteSummary_WithMarkdownLinkInPipelineSummary_RendersClickableLink( // Verify the markdown link was converted to a Spectre link Assert.Contains("VNetTest5", result); - Assert.Contains("link", result); - Assert.Contains("portal.azure.com", result); + + const string expectedUrl = + @"https://portal\.azure\.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview"; + string hyperlinkPattern = + $@"\u001b\]8;[^;]*;{expectedUrl}\u001b\\.*link.*\u001b\]8;;\u001b\\"; + Assert.Matches(hyperlinkPattern, result); } [Fact] @@ -56,7 +60,7 @@ public void WriteSummary_WithMarkdownLinkInPipelineSummary_NoColor_RendersPlainT var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: false); - var portalUrl = "https://portal.azure.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview"; + var portalUrl = "https://portal.azure.com/"; var summary = new List> { new("📦 Resource Group", $"VNetTest5 ([link]({portalUrl}))"), @@ -69,6 +73,7 @@ public void WriteSummary_WithMarkdownLinkInPipelineSummary_NoColor_RendersPlainT // When color is disabled, markdown links should be converted to plain text: text (url) Assert.Contains("VNetTest5", result); + Assert.Contains("link", result); Assert.Contains(portalUrl, result); } From 90fab63a9eb73e46043b18bfc84fe6afd4ec2be7 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 17 Feb 2026 11:38:53 -0600 Subject: [PATCH 4/8] More test fixups --- .../Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs index 8268fe43078..378c22e3b74 100644 --- a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs @@ -26,7 +26,7 @@ public void WriteSummary_WithMarkdownLinkInPipelineSummary_RendersClickableLink( var summary = new List> { new("☁️ Target", "Azure"), - new("📦 Resource Group", "VNetTest5 ([link](https://portal.azure.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview))"), + new("📦 Resource Group", "VNetTest5 [link](https://portal.azure.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview)"), new("🔑 Subscription", "sub-id"), new("🌐 Location", "eastus"), }; @@ -63,7 +63,7 @@ public void WriteSummary_WithMarkdownLinkInPipelineSummary_NoColor_RendersPlainT var portalUrl = "https://portal.azure.com/"; var summary = new List> { - new("📦 Resource Group", $"VNetTest5 ([link]({portalUrl}))"), + new("📦 Resource Group", $"VNetTest5 [link]({portalUrl})"), }; logger.SetFinalResult(true, summary); @@ -72,9 +72,7 @@ public void WriteSummary_WithMarkdownLinkInPipelineSummary_NoColor_RendersPlainT var result = output.ToString(); // When color is disabled, markdown links should be converted to plain text: text (url) - Assert.Contains("VNetTest5", result); - Assert.Contains("link", result); - Assert.Contains(portalUrl, result); + Assert.Contains($"VNetTest5 link ({portalUrl})", result); } [Fact] From 513ebb381f85e6df47158bab79e70749cfc901c5 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 17 Feb 2026 11:45:35 -0600 Subject: [PATCH 5/8] Refactor code --- src/Aspire.Cli/Utils/ConsoleActivityLogger.cs | 7 ++----- src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs | 10 ++++++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs index 78521083498..5c1153ddd55 100644 --- a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs +++ b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs @@ -15,7 +15,7 @@ namespace Aspire.Cli.Utils; /// rewriting the entire existing publishing pipeline. Integrates by mapping publish /// step/task events to Start/Progress/Success/Warning/Failure calls. /// -internal sealed partial class ConsoleActivityLogger +internal sealed class ConsoleActivityLogger { private readonly IAnsiConsole _console; private readonly bool _enableColor; @@ -317,14 +317,11 @@ private string FormatPipelineSummaryKvp(string key, string value) } else { - var plainValue = MarkdownLinkToPlainTextRegex().Replace(value, "$1 ($2)"); + var plainValue = MarkdownToSpectreConverter.ConvertLinksToPlainText(value); return $" {key}: {plainValue}"; } } - [GeneratedRegex(@"\[([^\]]+)\]\(([^)]+)\)")] - private static partial Regex MarkdownLinkToPlainTextRegex(); - /// /// Sets the final pipeline result lines to be displayed in the summary (e.g., PIPELINE FAILED ...). /// Optional usage so existing callers remain compatible. diff --git a/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs b/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs index bc8d3d5c6fd..0ae3635c010 100644 --- a/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs +++ b/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs @@ -61,6 +61,16 @@ public static string ConvertToSpectre(string markdown) return result; } + /// + /// Converts markdown links to plain text. + /// + /// The markdown text to convert. + /// The converted text in plain text. + public static string ConvertLinksToPlainText(string markdown) + { + return LinkRegex().Replace(markdown, "$1 ($2)"); + } + private static string ConvertHeaders(string text) { // Convert ###### Header 6 (most specific first) From 5a823b8843fdb7592b482e15d3fff5d400140b04 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 17 Feb 2026 12:13:52 -0600 Subject: [PATCH 6/8] Update src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs b/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs index 0ae3635c010..000fdfcc437 100644 --- a/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs +++ b/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs @@ -65,7 +65,7 @@ public static string ConvertToSpectre(string markdown) /// Converts markdown links to plain text. /// /// The markdown text to convert. - /// The converted text in plain text. + /// The text with markdown links converted to the plain text format text (url). public static string ConvertLinksToPlainText(string markdown) { return LinkRegex().Replace(markdown, "$1 ($2)"); From 78e16b90c6aa2841816be0bbc0360695ccf2afe9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:18:10 +0000 Subject: [PATCH 7/8] Add test for color-enabled non-interactive rendering path Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../Utils/ConsoleActivityLoggerTests.cs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs index 378c22e3b74..0624b4d6b3f 100644 --- a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs @@ -75,6 +75,41 @@ public void WriteSummary_WithMarkdownLinkInPipelineSummary_NoColor_RendersPlainT Assert.Contains($"VNetTest5 link ({portalUrl})", result); } + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_ColorWithoutInteractive_RendersPlainUrl() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + // Non-interactive host but color enabled (e.g., CI environments with ANSI support) + var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var portalUrl = "https://portal.azure.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview"; + var summary = new List> + { + new("📦 Resource Group", $"VNetTest5 [link]({portalUrl})"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // When color is enabled but interactive output is not supported, + // HighlightMessage converts Spectre link markup to plain URLs + Assert.Contains("VNetTest5", result); + Assert.Contains(portalUrl, result); + + // Should NOT contain the OSC 8 hyperlink escape sequence since we're non-interactive + Assert.DoesNotContain("\u001b]8;", result); + } + [Fact] public void WriteSummary_WithPlainTextPipelineSummary_RendersCorrectly() { From c7c25915548b0f1fd1959767e5cbbc79efbdafd0 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 17 Feb 2026 14:21:47 -0600 Subject: [PATCH 8/8] fix test --- tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs index 0624b4d6b3f..9410d688b19 100644 --- a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs @@ -90,7 +90,7 @@ public void WriteSummary_WithMarkdownLinkInPipelineSummary_ColorWithoutInteracti var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); - var portalUrl = "https://portal.azure.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview"; + var portalUrl = "https://portal.azure.com/"; var summary = new List> { new("📦 Resource Group", $"VNetTest5 [link]({portalUrl})"),