From cff9c4ce8890b1a54468232829be94713811219c Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 29 May 2026 01:10:51 +0200 Subject: [PATCH 01/15] Add Buildvana.Core.ConsoleOutput library Introduce host-agnostic contracts (IReporter, IActivityScope, MessageLevel, Verbosity, null stubs, and formatting extensions) in Buildvana.Core.Abstractions, plus a System.Console-backed ConsoleReporter in the new Buildvana.Core.ConsoleOutput project. The reporter writes leveled, color-coded lines without a logger category, groups work into activities (begin -> detail -> outcome), and passes child-process output through verbatim. BCL-only; no consumers yet. Co-Authored-By: Claude Opus 4.7 --- Buildvana.slnx | 1 + .../ConsoleOutput/IActivityScope.cs | 24 +++ .../ConsoleOutput/IReporter.cs | 47 +++++ .../ConsoleOutput/MessageLevel.cs | 32 ++++ .../ConsoleOutput/NullActivityScope.cs | 30 ++++ .../ConsoleOutput/NullReporter.cs | 41 +++++ .../ConsoleOutput/ReporterExtensions.cs | 100 +++++++++++ .../ConsoleOutput/Verbosity.cs | 31 ++++ .../Buildvana.Core.ConsoleOutput.csproj | 17 ++ .../ConsoleReporter.ActivityScope.cs | 45 +++++ .../ConsoleReporter.cs | 169 ++++++++++++++++++ 11 files changed, 537 insertions(+) create mode 100644 src/Buildvana.Core.Abstractions/ConsoleOutput/IActivityScope.cs create mode 100644 src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs create mode 100644 src/Buildvana.Core.Abstractions/ConsoleOutput/MessageLevel.cs create mode 100644 src/Buildvana.Core.Abstractions/ConsoleOutput/NullActivityScope.cs create mode 100644 src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs create mode 100644 src/Buildvana.Core.Abstractions/ConsoleOutput/ReporterExtensions.cs create mode 100644 src/Buildvana.Core.Abstractions/ConsoleOutput/Verbosity.cs create mode 100644 src/Buildvana.Core.ConsoleOutput/Buildvana.Core.ConsoleOutput.csproj create mode 100644 src/Buildvana.Core.ConsoleOutput/ConsoleReporter.ActivityScope.cs create mode 100644 src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs diff --git a/Buildvana.slnx b/Buildvana.slnx index 199282f..db368d7 100644 --- a/Buildvana.slnx +++ b/Buildvana.slnx @@ -39,6 +39,7 @@ + diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/IActivityScope.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/IActivityScope.cs new file mode 100644 index 0000000..6894cd7 --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/IActivityScope.cs @@ -0,0 +1,24 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// Represents an open activity: a "begin → detail → outcome" grouping of related work started by +/// . +/// +/// +/// Use with a using statement and call as the last statement of the block. The +/// outcome line is reported only when ran before disposal; if the scope is disposed +/// without completing (for example because the work threw), nothing is reported — much like a transaction that +/// is rolled back when not committed. +/// +public interface IActivityScope : IDisposable +{ + /// + /// Marks the activity as successfully completed, so that disposing the scope reports its outcome. + /// + void Complete(); +} diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs new file mode 100644 index 0000000..1669058 --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs @@ -0,0 +1,47 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// Reports human-facing console output: leveled messages, activity grouping, and verbatim child-process +/// passthrough. This is the primitive surface; formatting and per-level convenience helpers are provided as +/// extension methods (see ). +/// +public interface IReporter +{ + /// + /// Gets the verbosity that gates which s are rendered. + /// + Verbosity Verbosity { get; } + + /// + /// Reports a single message at the given . The message is rendered only when + /// is enabled by the current . + /// + /// The severity of the message. + /// The message text. + void Report(MessageLevel level, string message); + + /// + /// Begins an activity: a header is rendered now, and the returned scope renders the outcome (and elapsed + /// time) when disposed. + /// + /// A short description of the work the activity covers. + /// A scope that closes the activity when disposed. + IActivityScope BeginActivity(string title); + + /// + /// Writes a line from a child process's standard output verbatim: no level label, no color, no category. + /// Used to stream a spawned process's standard output through to this process's standard output. + /// + /// The line of child-process standard output to write. + void ChildOutput(string line); + + /// + /// Writes a line from a child process's standard error verbatim: no level label, no color, no category. + /// Used to stream a spawned process's standard error through to this process's standard error. + /// + /// The line of child-process standard error to write. + void ChildError(string line); +} diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/MessageLevel.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/MessageLevel.cs new file mode 100644 index 0000000..df74212 --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/MessageLevel.cs @@ -0,0 +1,32 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// The severity of a message reported through an . The level both classifies the +/// message and, together with the reporter's , decides whether it is shown. +/// +/// +/// Members are ordered from highest to lowest severity, mapping one-to-one onto the +/// thresholds: , , +/// , , +/// . +/// +public enum MessageLevel +{ + /// An error: something went wrong. Shown at every verbosity. + Error, + + /// A warning: something looks off but is not fatal. + Warning, + + /// An informational milestone. Shown at and above. + Info, + + /// A detail useful when following along closely. Shown at and above. + Detail, + + /// Fine-grained diagnostic chatter. Shown only at . + Trace, +} diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/NullActivityScope.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullActivityScope.cs new file mode 100644 index 0000000..9451224 --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullActivityScope.cs @@ -0,0 +1,30 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// An that does nothing. Returned by and usable +/// anywhere a non-rendering activity scope is needed. +/// +public sealed class NullActivityScope : IActivityScope +{ + private NullActivityScope() + { + } + + /// + /// Gets the singleton instance. + /// + public static NullActivityScope Instance { get; } = new(); + + /// + public void Complete() + { + } + + /// + public void Dispose() + { + } +} diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs new file mode 100644 index 0000000..9952eeb --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs @@ -0,0 +1,41 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// An that discards everything. Useful as a default argument or in tests that do not +/// assert on output. +/// +public sealed class NullReporter : IReporter +{ + private NullReporter() + { + } + + /// + /// Gets the singleton instance. + /// + public static NullReporter Instance { get; } = new(); + + /// + public Verbosity Verbosity => Verbosity.Quiet; + + /// + public void Report(MessageLevel level, string message) + { + } + + /// + public IActivityScope BeginActivity(string title) => NullActivityScope.Instance; + + /// + public void ChildOutput(string line) + { + } + + /// + public void ChildError(string line) + { + } +} diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/ReporterExtensions.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/ReporterExtensions.cs new file mode 100644 index 0000000..f20892f --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/ReporterExtensions.cs @@ -0,0 +1,100 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Globalization; +using System.Text; + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// Provides extension methods for instances: per-level message shortcuts and +/// -based formatting overloads. +/// +/// +/// All formatting uses . Cache the +/// instances passed to the formatting overloads in static readonly fields at the call site. +/// +#pragma warning disable CA1034 // Nested types should not be visible — false positive on C# 14 extension blocks; fixed in .NET 11, backport to .NET 10 requested in https://github.com/dotnet/sdk/issues/53984 +#pragma warning disable CA1708 // Identifiers should differ by more than case — false positive on classes with C# 14 extension blocks; fixed in .NET 11, https://github.com/dotnet/sdk/issues/51716 +public static class ReporterExtensions +{ + extension(IReporter @this) + { + /// Reports an message. + /// The message text. + public void Error(string message) => @this.Report(MessageLevel.Error, message); + + /// Reports a message. + /// The message text. + public void Warning(string message) => @this.Report(MessageLevel.Warning, message); + + /// Reports an message. + /// The message text. + public void Info(string message) => @this.Report(MessageLevel.Info, message); + + /// Reports a message. + /// The message text. + public void Detail(string message) => @this.Report(MessageLevel.Detail, message); + + /// Reports a message. + /// The message text. + public void Trace(string message) => @this.Report(MessageLevel.Trace, message); + + /// Formats and reports an message. + /// The composite format string. + /// The arguments to format. + public void Error(CompositeFormat format, params ReadOnlySpan args) + => @this.Report(MessageLevel.Error, format, args); + + /// Formats and reports a message. + /// The composite format string. + /// The arguments to format. + public void Warning(CompositeFormat format, params ReadOnlySpan args) + => @this.Report(MessageLevel.Warning, format, args); + + /// Formats and reports an message. + /// The composite format string. + /// The arguments to format. + public void Info(CompositeFormat format, params ReadOnlySpan args) + => @this.Report(MessageLevel.Info, format, args); + + /// Formats and reports a message. + /// The composite format string. + /// The arguments to format. + public void Detail(CompositeFormat format, params ReadOnlySpan args) + => @this.Report(MessageLevel.Detail, format, args); + + /// Formats and reports a message. + /// The composite format string. + /// The arguments to format. + public void Trace(CompositeFormat format, params ReadOnlySpan args) + => @this.Report(MessageLevel.Trace, format, args); + + /// + /// Formats and reports a message at the given . Formatting is skipped entirely + /// when is not enabled by the reporter's . + /// + /// The severity of the message. + /// The composite format string. + /// The arguments to format. + public void Report(MessageLevel level, CompositeFormat format, params ReadOnlySpan args) + { + ArgumentNullException.ThrowIfNull(format); + if (!@this.IsEnabled(level)) + { + return; + } + + @this.Report(level, string.Format(CultureInfo.InvariantCulture, format, args)); + } + + /// + /// Determines whether a message at the given would be rendered at the + /// reporter's current . + /// + /// The level to test. + /// if the level is enabled; otherwise, . + public bool IsEnabled(MessageLevel level) => (int)level <= (int)@this.Verbosity; + } +} diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/Verbosity.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/Verbosity.cs new file mode 100644 index 0000000..d468d59 --- /dev/null +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/Verbosity.cs @@ -0,0 +1,31 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// Controls how much of a reporter's output reaches the user. Each level enables all the +/// s enabled by the levels below it (see for the mapping). +/// +/// +/// The members mirror bv's --verbosity command-line vocabulary and are ordered from least to most +/// verbose, so a message at a given is shown when +/// (int)level <= (int)verbosity. +/// +public enum Verbosity +{ + /// Only errors are shown. + Quiet, + + /// Errors and warnings are shown. + Minimal, + + /// Errors, warnings, and informational messages are shown. This is the default. + Normal, + + /// Everything shows, plus detail messages. + Detailed, + + /// Everything is shown, including trace messages. + Diagnostic, +} diff --git a/src/Buildvana.Core.ConsoleOutput/Buildvana.Core.ConsoleOutput.csproj b/src/Buildvana.Core.ConsoleOutput/Buildvana.Core.ConsoleOutput.csproj new file mode 100644 index 0000000..9cd72ed --- /dev/null +++ b/src/Buildvana.Core.ConsoleOutput/Buildvana.Core.ConsoleOutput.csproj @@ -0,0 +1,17 @@ + + + + Buildvana console output + System.Console-backed IReporter: leveled human-facing console output with activity grouping and child-process passthrough. + $(StandardTfm) + + + + + + + + + + + diff --git a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.ActivityScope.cs b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.ActivityScope.cs new file mode 100644 index 0000000..4da6a4a --- /dev/null +++ b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.ActivityScope.cs @@ -0,0 +1,45 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Diagnostics; + +namespace Buildvana.Core.ConsoleOutput; + +public sealed partial class ConsoleReporter +{ + private sealed class ActivityScope : IActivityScope + { + private readonly ConsoleReporter _reporter; + private readonly long _startTimestamp; + private bool _completed; + private bool _disposed; + + public ActivityScope(ConsoleReporter reporter, string title, int depth) + { + _reporter = reporter; + Title = title; + Depth = depth; + _startTimestamp = Stopwatch.GetTimestamp(); + } + + public string Title { get; } + + public int Depth { get; } + + public TimeSpan Elapsed => Stopwatch.GetElapsedTime(_startTimestamp); + + public void Complete() => _completed = true; + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _reporter.EndActivity(this, _completed); + } + } +} diff --git a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs new file mode 100644 index 0000000..4ee8fec --- /dev/null +++ b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs @@ -0,0 +1,169 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Core.ConsoleOutput; + +/// +/// An that writes to the process's standard output via . +/// +/// +/// Color is a function of message level, decided here and never by the caller: error: renders in +/// red and warning: in yellow (foreground only, no background fill); the remaining levels are uncolored +/// so they inherit the terminal's theme. The message body is never colored. +/// Output is serialized through an internal lock so that lines streamed from a child process's standard +/// output and standard error (which arrive on background threads) never interleave mid-line with each other or +/// with narration. +/// +public sealed partial class ConsoleReporter : IReporter +{ + private readonly Lock _writeLock = new(); + private readonly Stack _activityStack = new(); + private readonly bool _useColor; + + /// + /// Initializes a new instance of the class. + /// + /// The verbosity that gates which message levels are rendered. + /// + /// to force color on, to force it off, or + /// to auto-detect (color on unless output is redirected or the NO_COLOR + /// environment variable is set). A non- value wins over both, so --color + /// overrides NO_COLOR. + /// + public ConsoleReporter(Verbosity verbosity, bool? colorOverride) + { + Verbosity = verbosity; + _useColor = colorOverride ?? (!IsNoColorSet() && !Console.IsOutputRedirected); + } + + /// + public Verbosity Verbosity { get; } + + /// + public void Report(MessageLevel level, string message) + { + Guard.IsNotNull(message); + if (!this.IsEnabled(level)) + { + return; + } + + lock (_writeLock) + { + WriteLeveledLine(level, message); + } + } + + /// + public IActivityScope BeginActivity(string title) + { + Guard.IsNotNullOrEmpty(title); + lock (_writeLock) + { + var depth = _activityStack.Count + 1; + var scope = new ActivityScope(this, title, depth); + _activityStack.Push(scope); + if (this.IsEnabled(MessageLevel.Info)) + { + Console.WriteLine(FormatActivityLine(depth, title, elapsed: null)); + } + + return scope; + } + } + + /// + public void ChildOutput(string line) + { + Guard.IsNotNull(line); + + // Quiet swallows child output; the process runner's head/tail buffer still provides a failure tail. + if (Verbosity == Verbosity.Quiet) + { + return; + } + + lock (_writeLock) + { + Console.WriteLine(line); + } + } + + /// + public void ChildError(string line) + { + Guard.IsNotNull(line); + + // As with ChildOutput, Quiet swallows the live stream and relies on the head/tail failure tail. + if (Verbosity == Verbosity.Quiet) + { + return; + } + + lock (_writeLock) + { + Console.Error.WriteLine(line); + } + } + + private static bool IsNoColorSet() => Environment.GetEnvironmentVariable("NO_COLOR") is { Length: > 0 }; + + private static (ConsoleColor? Color, string Word) StyleFor(MessageLevel level) => level switch + { + MessageLevel.Error => (ConsoleColor.Red, "error"), + MessageLevel.Warning => (ConsoleColor.Yellow, "warning"), + MessageLevel.Info => (null, "info"), + MessageLevel.Detail => (null, "detail"), + MessageLevel.Trace => (null, "trace"), + _ => ThrowHelper.ThrowArgumentOutOfRangeException<(ConsoleColor?, string)>(nameof(level), level, "Unknown message level."), + }; + + // Activity header/outcome lines are label-less; the leading "[depth]" conveys nesting without indentation. + private static string FormatActivityLine(int depth, string title, TimeSpan? elapsed) + => elapsed is { } e + ? string.Format(CultureInfo.InvariantCulture, "[{0}] {1}: done ({2:F1}s)", depth, title, e.TotalSeconds) + : string.Format(CultureInfo.InvariantCulture, "[{0}] {1}", depth, title); + + private void WriteLeveledLine(MessageLevel level, string message) + { + var (color, word) = StyleFor(level); + if (_useColor && color is { } foreground) + { + Console.ForegroundColor = foreground; + Console.Write(word); + Console.Write(':'); + Console.ResetColor(); + } + else + { + Console.Write(word); + Console.Write(':'); + } + + Console.Write(' '); + Console.WriteLine(message); + } + + private void EndActivity(ActivityScope scope, bool completed) + { + lock (_writeLock) + { + if (_activityStack.Count > 0 && ReferenceEquals(_activityStack.Peek(), scope)) + { + _activityStack.Pop(); + } + + // No outcome line unless the activity was explicitly completed (e.g. the work threw before Complete). + if (completed && this.IsEnabled(MessageLevel.Detail)) + { + Console.WriteLine(FormatActivityLine(scope.Depth, scope.Title, scope.Elapsed)); + } + } + } +} From aac560e0189c1d734acee466e84050838193856c Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 29 May 2026 01:11:11 +0200 Subject: [PATCH 02/15] Fan out child standard error through IProcessRunner RunAsync streamed only standard output line-by-line; standard error was captured but never surfaced live. Add an onStderr callback alongside onStdout so a caller can stream both, keeping the two distinguishable. Co-Authored-By: Claude Opus 4.7 --- .../Process/IProcessRunner.cs | 3 +++ src/Buildvana.Core.Process/ProcessRunner.cs | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Buildvana.Core.Abstractions/Process/IProcessRunner.cs b/src/Buildvana.Core.Abstractions/Process/IProcessRunner.cs index 36fcdc7..9f70ed4 100644 --- a/src/Buildvana.Core.Abstractions/Process/IProcessRunner.cs +++ b/src/Buildvana.Core.Abstractions/Process/IProcessRunner.cs @@ -22,6 +22,8 @@ public interface IProcessRunner /// If (the default), a is thrown when the process exits with a non-zero exit code; if , the result is returned regardless of exit code. /// An optional callback invoked once per line of standard output as it is produced. /// The full output text is captured into the returned regardless. + /// An optional callback invoked once per line of standard error as it is produced. + /// The full error text is captured into the returned regardless. /// A used to cancel the process. /// A describing the process's outcome. Task RunAsync( @@ -30,5 +32,6 @@ Task RunAsync( string? workingDirectory = null, bool throwOnNonZero = true, Action? onStdout = null, + Action? onStderr = null, CancellationToken cancellationToken = default); } diff --git a/src/Buildvana.Core.Process/ProcessRunner.cs b/src/Buildvana.Core.Process/ProcessRunner.cs index 9ff3cc9..5ad080a 100644 --- a/src/Buildvana.Core.Process/ProcessRunner.cs +++ b/src/Buildvana.Core.Process/ProcessRunner.cs @@ -28,6 +28,7 @@ public async Task RunAsync( string? workingDirectory = null, bool throwOnNonZero = true, Action? onStdout = null, + Action? onStderr = null, CancellationToken cancellationToken = default) { Guard.IsNotNullOrEmpty(executable); @@ -50,7 +51,15 @@ public async Task RunAsync( PipeTarget.ToStringBuilder(stdoutBuffer), PipeTarget.ToDelegate(onStdout)); - var stderrPipe = PipeTarget.Merge(stderrCapture, PipeTarget.ToStringBuilder(stderrBuffer)); + // When the caller wants line-by-line stderr, fan the stream out to their callback too. + var stderrPipe = onStderr is null + ? PipeTarget.Merge( + stderrCapture, + PipeTarget.ToStringBuilder(stderrBuffer)) + : PipeTarget.Merge( + stderrCapture, + PipeTarget.ToStringBuilder(stderrBuffer), + PipeTarget.ToDelegate(onStderr)); var command = Cli.Wrap(executable) From 757044dc74496cbe0d779dd307915eba35ddd3e0 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 29 May 2026 01:42:31 +0200 Subject: [PATCH 03/15] Replace bv's Microsoft.Extensions.Logging usage with IReporter bv used Microsoft.Extensions.Logging purely to write to the console. Migrate every call site to IReporter: class-name categories are gone, messages render as clean color-coded lines, and dotnet/MSBuild output now streams through live (stdout to stdout, stderr to stderr) instead of being hidden unless the build fails. Pipeline steps and the release flow are grouped into activities. Delete the SpectreLogger bridge, drop the Microsoft.Extensions.Logging package references from the tool and from Buildvana.Core.Abstractions, and give Buildvana.Sdk.Tasks an explicit Microsoft.Extensions.Logging.Abstractions reference (it previously resolved it transitively through Abstractions). Verbosity behavior is unchanged. Closes #286 Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + Directory.Packages.props | 1 - .../Buildvana.Core.Abstractions.csproj | 1 - .../Buildvana.Sdk.Tasks.csproj | 1 + src/Buildvana.Tool/Build/BuildPipeline.cs | 34 ++++++---- src/Buildvana.Tool/Buildvana.Tool.csproj | 3 +- .../Infrastructure/Logging/SpectreLogger.cs | 68 ------------------- .../Logging/SpectreLoggerProvider.cs | 53 --------------- src/Buildvana.Tool/Program.cs | 51 ++++++++------ .../Services/ChangelogService.cs | 14 ++-- src/Buildvana.Tool/Services/DocFxService.cs | 8 +-- src/Buildvana.Tool/Services/DotNetService.cs | 51 ++++++++------ src/Buildvana.Tool/Services/Git/GitService.cs | 38 +++++------ .../PublicApiFiles/PublicApiFilesService.cs | 18 ++--- .../Services/SelfReferenceUpdater.cs | 29 ++++---- .../Internal/GitHub/GitHubServerAdapter.cs | 28 ++++---- .../Internal/GitHub/GitHubServerRelease.cs | 18 +++-- .../Services/ServerAdapters/ServerRelease.cs | 23 +++---- .../Services/Versioning/VersionService.cs | 28 ++++---- .../Subcommands/ReleaseCommand.cs | 60 ++++++++-------- .../Utilities/FileSystemHelper.cs | 12 ++-- 21 files changed, 215 insertions(+), 325 deletions(-) delete mode 100644 src/Buildvana.Tool/Infrastructure/Logging/SpectreLogger.cs delete mode 100644 src/Buildvana.Tool/Infrastructure/Logging/SpectreLoggerProvider.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc68df..289d8d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `diagnostic` (or `diag`) Cake verbosity values (e.g., `verbose`) are no longer accepted. +- `bv` no longer prefixes its console output with a log level and a class-name category (e.g. `info: Buildvana.Tool.Services.DotNetService: ...`). Messages now render as clean, color-coded lines: errors in red and warnings in yellow, each line tagged with a short level label (`error:`/`warning:`/`info:`/`detail:`/`trace:`). In addition, `dotnet`/MSBuild output is now streamed through live (standard output to `bv`'s standard output, standard error to its standard error) instead of being hidden unless the build fails; on failure, the first and last lines of the captured output are still included in the error message. Verbosity behavior is unchanged (`--verbosity quiet|minimal|normal|detailed|diagnostic`), as is the handling of `--color`/`--no-color` (with the `NO_COLOR` environment variable now honored as well). - **BREAKING CHANGE**: `bv restore`, `bv build`, `bv test`, and `bv pack` forward extra command-line arguments to the underlying `dotnet` invocation(s) only after a `--` separator: everything after the first `--` is passed through verbatim, in the order given, and `bv` no longer parses or validates it. A non-global, option-looking token _before_ `--` is now an error that points you at the separator. Malformed or unknown forwarded arguments produce an error from `dotnet` (or, for `bv test`, from the Microsoft.Testing.Platform test application) rather than from `bv`. Previously only `-p:`/`/p:` MSBuild properties were forwarded. `bv` also always forwards `--nologo` and its resolved `--verbosity` (default `normal`) to those invocations. - `bv build -- -m:8 -v:minimal` forwards `-m:8 -v:minimal` to `dotnet build`. - `bv test -- --report-trx` reaches the test application. diff --git a/Directory.Packages.props b/Directory.Packages.props index 5244ef3..d7d81fa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,7 +45,6 @@ - diff --git a/src/Buildvana.Core.Abstractions/Buildvana.Core.Abstractions.csproj b/src/Buildvana.Core.Abstractions/Buildvana.Core.Abstractions.csproj index 4573467..d49630b 100644 --- a/src/Buildvana.Core.Abstractions/Buildvana.Core.Abstractions.csproj +++ b/src/Buildvana.Core.Abstractions/Buildvana.Core.Abstractions.csproj @@ -10,7 +10,6 @@ - diff --git a/src/Buildvana.Sdk.Tasks/Buildvana.Sdk.Tasks.csproj b/src/Buildvana.Sdk.Tasks/Buildvana.Sdk.Tasks.csproj index a544228..a539a26 100644 --- a/src/Buildvana.Sdk.Tasks/Buildvana.Sdk.Tasks.csproj +++ b/src/Buildvana.Sdk.Tasks/Buildvana.Sdk.Tasks.csproj @@ -25,6 +25,7 @@ + diff --git a/src/Buildvana.Tool/Build/BuildPipeline.cs b/src/Buildvana.Tool/Build/BuildPipeline.cs index 341263f..e408c27 100644 --- a/src/Buildvana.Tool/Build/BuildPipeline.cs +++ b/src/Buildvana.Tool/Build/BuildPipeline.cs @@ -5,13 +5,13 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.CommandLine; using Buildvana.Tool.Infrastructure; using Buildvana.Tool.Services; using Buildvana.Tool.Services.Solution; using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Build; @@ -30,7 +30,7 @@ internal sealed class BuildPipeline private readonly SolutionContext _solution; private readonly DotNetService _dotnet; - private readonly ILoggerFactory _loggerFactory; + private readonly IReporter _reporter; private readonly IReadOnlyList _forwardedArgs; /// @@ -39,16 +39,16 @@ internal sealed class BuildPipeline public BuildPipeline( SolutionContext solution, DotNetService dotnet, - ILoggerFactory loggerFactory, + IReporter reporter, CommandParameters parameters) { Guard.IsNotNull(solution); Guard.IsNotNull(dotnet); - Guard.IsNotNull(loggerFactory); + Guard.IsNotNull(reporter); Guard.IsNotNull(parameters); _solution = solution; _dotnet = dotnet; - _loggerFactory = loggerFactory; + _reporter = reporter; _forwardedArgs = parameters.Forwarded; } @@ -87,8 +87,10 @@ public async Task RunRangeAsync(BuildStep first, BuildStep last, string configur /// The MSBuild configuration to build (ignored by and ). /// A token to observe while running the step. When signalled, the running dotnet child process is terminated. /// A representing the ongoing operation. - public Task RunAsync(BuildStep step, string configuration = DefaultConfiguration, CancellationToken cancellationToken = default) - => step switch + public async Task RunAsync(BuildStep step, string configuration = DefaultConfiguration, CancellationToken cancellationToken = default) + { + using var activity = _reporter.BeginActivity(step.ToString()); + var task = step switch { BuildStep.Clean => CleanAsync(cancellationToken), BuildStep.Restore => RestoreAsync(cancellationToken), @@ -97,21 +99,23 @@ public Task RunAsync(BuildStep step, string configuration = DefaultConfiguration BuildStep.Pack => PackAsync(configuration, cancellationToken), _ => ThrowHelper.ThrowArgumentOutOfRangeException(nameof(step), step, "Unknown build step."), }; + await task.ConfigureAwait(false); + activity.Complete(); + } private Task CleanAsync(CancellationToken cancellationToken) { - var logger = _loggerFactory.CreateLogger("Clean"); - FileSystemHelper.DeleteDirectory(_solution.ResolvePath(".vs"), logger); - FileSystemHelper.DeleteDirectory(_solution.ResolvePath("_ReSharper.Caches"), logger); - FileSystemHelper.DeleteDirectory(_solution.ResolvePath("temp"), logger); - FileSystemHelper.DeleteDirectory(_solution.ResolvePath(CommonPaths.AllArtifacts), logger); - FileSystemHelper.DeleteDirectory(_solution.ResolvePath(CommonPaths.TestResults), logger); + FileSystemHelper.DeleteDirectory(_solution.ResolvePath(".vs"), _reporter); + FileSystemHelper.DeleteDirectory(_solution.ResolvePath("_ReSharper.Caches"), _reporter); + FileSystemHelper.DeleteDirectory(_solution.ResolvePath("temp"), _reporter); + FileSystemHelper.DeleteDirectory(_solution.ResolvePath(CommonPaths.AllArtifacts), _reporter); + FileSystemHelper.DeleteDirectory(_solution.ResolvePath(CommonPaths.TestResults), _reporter); foreach (var project in _solution.Model.SolutionProjects) { cancellationToken.ThrowIfCancellationRequested(); var projectDirectory = Path.GetDirectoryName(_solution.ResolveProjectPath(project))!; - FileSystemHelper.DeleteDirectory(Path.Combine(projectDirectory, "bin"), logger); - FileSystemHelper.DeleteDirectory(Path.Combine(projectDirectory, "obj"), logger); + FileSystemHelper.DeleteDirectory(Path.Combine(projectDirectory, "bin"), _reporter); + FileSystemHelper.DeleteDirectory(Path.Combine(projectDirectory, "obj"), _reporter); } return Task.CompletedTask; diff --git a/src/Buildvana.Tool/Buildvana.Tool.csproj b/src/Buildvana.Tool/Buildvana.Tool.csproj index 104ea37..33cf7a9 100644 --- a/src/Buildvana.Tool/Buildvana.Tool.csproj +++ b/src/Buildvana.Tool/Buildvana.Tool.csproj @@ -25,6 +25,7 @@ + @@ -40,8 +41,6 @@ - - diff --git a/src/Buildvana.Tool/Infrastructure/Logging/SpectreLogger.cs b/src/Buildvana.Tool/Infrastructure/Logging/SpectreLogger.cs deleted file mode 100644 index c0df901..0000000 --- a/src/Buildvana.Tool/Infrastructure/Logging/SpectreLogger.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System; -using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; -using Spectre.Console; - -namespace Buildvana.Tool.Infrastructure.Logging; - -internal sealed class SpectreLogger : ILogger -{ - private readonly IAnsiConsole _console; - private readonly string _categoryName; - private readonly SpectreLoggerProvider _provider; - - public SpectreLogger(IAnsiConsole console, string categoryName, SpectreLoggerProvider provider) - { - _console = console; - _categoryName = categoryName; - _provider = provider; - } - - public IDisposable? BeginScope(TState state) - where TState : notnull - => null; - - public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None && logLevel >= _provider.MinLevel; - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - Guard.IsNotNull(formatter); - if (!IsEnabled(logLevel)) - { - return; - } - - var message = formatter(state, exception); - if (string.IsNullOrEmpty(message) && exception is null) - { - return; - } - - var (style, label) = logLevel switch { - LogLevel.Trace => ("grey", "trce"), - LogLevel.Debug => ("grey", "dbug"), - LogLevel.Information => ("white", "info"), - LogLevel.Warning => ("yellow", "warn"), - LogLevel.Error => ("red", "fail"), - LogLevel.Critical => ("red bold", "crit"), - _ => ("white", "????"), - }; - - // Markup.Escape: log messages may contain '[' or ']' which Spectre would otherwise interpret as markup. - _console.MarkupLine( - $"[{style}]{label}[/]: [silver]{Markup.Escape(_categoryName)}[/]: {Markup.Escape(message)}"); - - if (exception is not null) - { - _console.WriteException(exception); - } - } -} diff --git a/src/Buildvana.Tool/Infrastructure/Logging/SpectreLoggerProvider.cs b/src/Buildvana.Tool/Infrastructure/Logging/SpectreLoggerProvider.cs deleted file mode 100644 index d6b7cb8..0000000 --- a/src/Buildvana.Tool/Infrastructure/Logging/SpectreLoggerProvider.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. -// See the LICENSE file in the project root for full license information. - -using System; -using System.Collections.Concurrent; -using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; -using Spectre.Console; - -namespace Buildvana.Tool.Infrastructure.Logging; - -/// -/// An that writes log entries through an . -/// -/// -/// TTY awareness is delegated to the supplied : when output is redirected, -/// Spectre.Console's default profile downgrades to plain ASCII automatically, so this provider needs no -/// extra detection logic of its own. -/// The provider does not own the supplied console; is a no-op. -/// -internal sealed class SpectreLoggerProvider : ILoggerProvider -{ - private readonly IAnsiConsole _console; - private readonly ConcurrentDictionary _loggers = new(StringComparer.Ordinal); - - /// - /// Initializes a new instance of the class. - /// - /// The console to which log entries are written. - public SpectreLoggerProvider(IAnsiConsole console) - { - Guard.IsNotNull(console); - _console = console; - } - - /// - /// Gets or sets the minimum level emitted by loggers from this provider. Mutable so the entry command - /// can apply --verbosity after parsing settings. - /// - public LogLevel MinLevel { get; set; } = LogLevel.Information; - - /// - public ILogger CreateLogger(string categoryName) - { - Guard.IsNotNull(categoryName); - return _loggers.GetOrAdd(categoryName, name => new SpectreLogger(_console, name, this)); - } - - /// - public void Dispose() - { - } -} diff --git a/src/Buildvana.Tool/Program.cs b/src/Buildvana.Tool/Program.cs index 88a3e29..ce28042 100644 --- a/src/Buildvana.Tool/Program.cs +++ b/src/Buildvana.Tool/Program.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Buildvana.Core; using Buildvana.Core.Configuration; +using Buildvana.Core.ConsoleOutput; using Buildvana.Core.HomeDirectory; using Buildvana.Core.Json; using Buildvana.Core.Process; @@ -13,7 +14,6 @@ using Buildvana.Tool.CommandLine; using Buildvana.Tool.Configuration; using Buildvana.Tool.Infrastructure.Execution; -using Buildvana.Tool.Infrastructure.Logging; using Buildvana.Tool.Services; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.PublicApiFiles; @@ -22,7 +22,6 @@ using Buildvana.Tool.Services.Versioning; using Buildvana.Tool.Subcommands; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Spectre.Console; namespace Buildvana.Tool; @@ -36,6 +35,10 @@ public static async Task Main(string[] args) { var console = AnsiConsole.Console; + // Assigned once --verbosity and --color/--no-color are known. The outer catch falls back to a default + // reporter for errors that occur before that point (e.g. an invalid --verbosity value). + IReporter? reporter = null; + try { var parsed = CliArgSplitter.Split(args); @@ -77,11 +80,19 @@ public static async Task Main(string[] args) CommandArgumentValidator.Validate(command, parsed); - // Parse --verbosity eagerly (so the error surfaces in the outer catch) but defer SpectreLoggerProvider - // construction to the DI factory below, so the container owns disposal. - var initialLogLevel = globals.Verbosity is null ? LogLevel.Information : ParseVerbosity(globals.Verbosity); + // Parse --verbosity eagerly so an invalid value surfaces in the outer catch. + var verbosity = globals.Verbosity is null ? Verbosity.Normal : ParseVerbosity(globals.Verbosity); + + // --color / --no-color win over auto-detection; neither (or both) leaves the reporter to auto-detect. + bool? colorOverride = (globals.Color, globals.NoColor) switch + { + (true, false) => true, + (false, true) => false, + _ => null, + }; + reporter = new ConsoleReporter(verbosity, colorOverride); - var services = BuildServiceProvider(console, globals, parsed, initialLogLevel); + var services = BuildServiceProvider(reporter, globals, parsed); await using (services.ConfigureAwait(false)) { var cts = new CancellationTokenSource(); @@ -130,30 +141,28 @@ void OnCancel(object? sender, ConsoleCancelEventArgs e) } catch (OperationCanceledException) { - console.MarkupLine("[yellow]Operation cancelled.[/]"); + (reporter ?? CreateDefaultReporter()).Error("Operation cancelled."); return CancelledExitCode; } catch (BuildFailedException ex) { - console.MarkupLineInterpolated($"[red]{ex.Message}[/]"); + (reporter ?? CreateDefaultReporter()).Error(ex.Message); return ex.ExitCode; } + + static IReporter CreateDefaultReporter() => new ConsoleReporter(Verbosity.Normal, colorOverride: null); } private static ServiceProvider BuildServiceProvider( - IAnsiConsole console, + IReporter reporter, GlobalSettings globals, - ParsedCommandLine parsed, - LogLevel initialLogLevel) + ParsedCommandLine parsed) { var services = new ServiceCollection() - .AddSingleton(console) - .AddSingleton(_ => new SpectreLoggerProvider(console) { MinLevel = initialLogLevel }) - .AddSingleton(static sp => sp.GetRequiredService()) + .AddSingleton(reporter) .AddSingleton(globals) .AddSingleton(new CommandParameters(parsed.OptionTokens, parsed.Forwarded)) .AddSingleton(static sp => ReleaseSettings.Parse(sp.GetRequiredService().Options)) - .AddLogging(static builder => builder.SetMinimumLevel(LogLevel.Trace)) .AddSingleton(static _ => new DiscoveredHomeDirectoryProvider(Environment.CurrentDirectory)) // Lazy by design: this factory (and thus discovery, parsing, and validation) runs on first resolve. @@ -184,13 +193,13 @@ private static ServiceProvider BuildServiceProvider( return services.BuildServiceProvider(); } - private static LogLevel ParseVerbosity(string raw) => raw.ToUpperInvariant() switch + private static Verbosity ParseVerbosity(string raw) => raw.ToUpperInvariant() switch { - "QUIET" or "Q" => LogLevel.Error, - "MINIMAL" or "M" => LogLevel.Warning, - "NORMAL" or "N" => LogLevel.Information, - "DETAILED" or "D" => LogLevel.Debug, - "DIAGNOSTIC" or "DIAG" => LogLevel.Trace, + "QUIET" or "Q" => Verbosity.Quiet, + "MINIMAL" or "M" => Verbosity.Minimal, + "NORMAL" or "N" => Verbosity.Normal, + "DETAILED" or "D" => Verbosity.Detailed, + "DIAGNOSTIC" or "DIAG" => Verbosity.Diagnostic, _ => throw new BuildFailedException($"Unknown verbosity level '{raw}'. Use one of: quiet, minimal, normal, detailed, diagnostic."), }; } diff --git a/src/Buildvana.Tool/Services/ChangelogService.cs b/src/Buildvana.Tool/Services/ChangelogService.cs index 3483987..7a709a5 100644 --- a/src/Buildvana.Tool/Services/ChangelogService.cs +++ b/src/Buildvana.Tool/Services/ChangelogService.cs @@ -9,10 +9,10 @@ using System.Text; using System.Text.RegularExpressions; using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.Services.ServerAdapters; using Buildvana.Tool.Services.Versioning; using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Services; @@ -26,19 +26,19 @@ internal sealed partial class ChangelogService /// public const string FileName = "CHANGELOG.md"; - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly ServerAdapter _server; private readonly VersionService _version; /// /// Initializes a new instance of the class. /// - public ChangelogService(ILogger logger, ServerAdapter server, VersionService version) + public ChangelogService(IReporter reporter, ServerAdapter server, VersionService version) { - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); Guard.IsNotNull(server); Guard.IsNotNull(version); - _logger = logger; + _reporter = reporter; _server = server; _version = version; Exists = File.Exists(FileName); @@ -94,7 +94,7 @@ public bool HasUnreleasedChanges() /// public void PrepareForRelease() { - _logger.LogInformation("Updating changelog..."); + _reporter.Info("Updating changelog..."); var encoding = new UTF8Encoding(false, true); var sb = new StringBuilder(); using (var reader = new StreamReader(FileName, encoding)) @@ -224,7 +224,7 @@ List CollectNewSectionLines() /// public void UpdateNewSectionTitle() { - _logger.LogInformation("Updating changelog's new release section title..."); + _reporter.Info("Updating changelog's new release section title..."); var encoding = new UTF8Encoding(false, true); var sb = new StringBuilder(); using (var reader = new StreamReader(FileName, encoding)) diff --git a/src/Buildvana.Tool/Services/DocFxService.cs b/src/Buildvana.Tool/Services/DocFxService.cs index 431f70b..7335bcc 100644 --- a/src/Buildvana.Tool/Services/DocFxService.cs +++ b/src/Buildvana.Tool/Services/DocFxService.cs @@ -4,11 +4,11 @@ using System.IO; using System.Text.Json; using System.Threading.Tasks; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.Infrastructure; using Buildvana.Tool.Services.ServerAdapters; using Buildvana.Tool.Services.Versioning; using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Services; @@ -27,11 +27,11 @@ internal sealed class DocFxService /// Initializes a new instance of the class. /// public DocFxService( - ILogger logger, + IReporter reporter, ServerAdapter server, VersionService version) { - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); Guard.IsNotNull(server); Guard.IsNotNull(version); @@ -42,7 +42,7 @@ public DocFxService( IsEnabled = File.Exists(_configPath); if (!IsEnabled) { - logger.LogInformation("{ConfigPath} not found: DocFX operations will be skipped.", _configPath); + reporter.Info($"{_configPath} not found: DocFX operations will be skipped."); } } diff --git a/src/Buildvana.Tool/Services/DotNetService.cs b/src/Buildvana.Tool/Services/DotNetService.cs index 9cd4b53..712e747 100644 --- a/src/Buildvana.Tool/Services/DotNetService.cs +++ b/src/Buildvana.Tool/Services/DotNetService.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.Configuration; using Buildvana.Tool.Infrastructure; using Buildvana.Tool.Services.ServerAdapters; @@ -15,7 +16,6 @@ using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using IProcessRunner = Buildvana.Core.Process.IProcessRunner; using ProcessResult = Buildvana.Core.Process.ProcessResult; @@ -34,7 +34,7 @@ private static readonly string DotNetMuxer ? p : "dotnet"; - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly IProcessRunner _processRunner; private readonly IServiceProvider _services; private readonly ServerAdapter _server; @@ -45,20 +45,20 @@ private static readonly string DotNetMuxer /// Initializes a new instance of the class. /// public DotNetService( - ILogger logger, + IReporter reporter, IProcessRunner processRunner, IServiceProvider services, ServerAdapter server, VersionService version, GlobalSettings globals) { - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); Guard.IsNotNull(processRunner); Guard.IsNotNull(services); Guard.IsNotNull(server); Guard.IsNotNull(version); Guard.IsNotNull(globals); - _logger = logger; + _reporter = reporter; _processRunner = processRunner; _services = services; _server = server; @@ -81,11 +81,11 @@ public Task RestoreSolutionAsync(SolutionContext solution, IReadOnlyList { Guard.IsNotNull(solution); Guard.IsNotNull(forwardedArgs); - _logger.LogInformation("Restoring NuGet packages for solution..."); + _reporter.Info("Restoring NuGet packages for solution..."); List args = ["restore", solution.SolutionPath, "--disable-parallel", "-nologo", "-v", Verbosity]; args.AddRange(forwardedArgs); args.Add(ContinuousIntegrationBuildArg(dotnetTest: false)); - return RunDotNetAsync(args, cancellationToken); + return RunDotNetAsync(args, cancellationToken: cancellationToken); } /// @@ -102,7 +102,7 @@ public Task BuildSolutionAsync(SolutionContext solution, string configuration, I Guard.IsNotNull(solution); Guard.IsNotNullOrEmpty(configuration); Guard.IsNotNull(forwardedArgs); - _logger.LogInformation("Building solution (restore = {Restore})...", restore); + _reporter.Info($"Building solution (restore = {restore})..."); List args = ["build", solution.SolutionPath, "-nologo", "-v", Verbosity, $"-p:Configuration={configuration}"]; if (!restore) { @@ -111,7 +111,7 @@ public Task BuildSolutionAsync(SolutionContext solution, string configuration, I args.AddRange(forwardedArgs); args.Add(ContinuousIntegrationBuildArg(dotnetTest: false)); - return RunDotNetAsync(args, cancellationToken); + return RunDotNetAsync(args, cancellationToken: cancellationToken); } /// @@ -130,21 +130,21 @@ public async Task TestSolutionAsync(SolutionContext solution, string configurati Guard.IsNotNull(solution); Guard.IsNotNullOrEmpty(configuration); Guard.IsNotNull(forwardedArgs); - _logger.LogInformation("Checking for MTP test projects..."); + _reporter.Info("Checking for MTP test projects..."); var hasTestProjects = false; foreach (var project in solution.Model.SolutionProjects) { var projectPath = solution.ResolveProjectPath(project); - _logger.LogDebug("Checking '{Path}'...", projectPath); + _reporter.Detail($"Checking '{projectPath}'..."); // bv-internal MSBuild evaluation: do not forward the user's arguments here, as they may be // test-application options that `dotnet msbuild` would reject. List probeArgs = ["msbuild", projectPath, "-nologo", "-getProperty:IsTestingPlatformApplication"]; - var probe = await RunDotNetAsync(probeArgs, cancellationToken).ConfigureAwait(false); + var probe = await RunDotNetAsync(probeArgs, streamOutput: false, cancellationToken: cancellationToken).ConfigureAwait(false); if (string.Equals(probe.StandardOutput.Trim(), "true", StringComparison.OrdinalIgnoreCase)) { - _logger.LogDebug("Project '{Path}' is a test project, will run tests.", projectPath); + _reporter.Detail($"Project '{projectPath}' is a test project, will run tests."); hasTestProjects = true; break; } @@ -152,11 +152,11 @@ public async Task TestSolutionAsync(SolutionContext solution, string configurati if (!hasTestProjects) { - _logger.LogInformation("No test projects found, skipping tests."); + _reporter.Info("No test projects found, skipping tests."); return; } - _logger.LogInformation("Running tests (restore = {Restore}, build = {Build})...", restore, build); + _reporter.Info($"Running tests (restore = {restore}, build = {build})..."); // `dotnet test` consumes --verbosity itself; the configuration and ContinuousIntegrationBuild are // passed as MSBuild properties using the `--property:` form, which is what `dotnet test` understands @@ -175,7 +175,7 @@ public async Task TestSolutionAsync(SolutionContext solution, string configurati args.AddRange(["--coverage", "--coverage-output-format", "cobertura", "--results-directory", CommonPaths.TestResults]); args.AddRange(forwardedArgs); args.Add(ContinuousIntegrationBuildArg(dotnetTest: true)); - await RunDotNetAsync(args, cancellationToken).ConfigureAwait(false); + await RunDotNetAsync(args, cancellationToken: cancellationToken).ConfigureAwait(false); } /// @@ -193,7 +193,7 @@ public Task PackSolutionAsync(SolutionContext solution, string configuration, IR Guard.IsNotNull(solution); Guard.IsNotNullOrEmpty(configuration); Guard.IsNotNull(forwardedArgs); - _logger.LogInformation("Packing solution (restore = {Restore}, build = {Build})...", restore, build); + _reporter.Info($"Packing solution (restore = {restore}, build = {build})..."); List args = ["pack", solution.SolutionPath, "-nologo", "-v", Verbosity, $"-p:Configuration={configuration}"]; if (!build) { @@ -207,7 +207,7 @@ public Task PackSolutionAsync(SolutionContext solution, string configuration, IR args.AddRange(forwardedArgs); args.Add(ContinuousIntegrationBuildArg(dotnetTest: false)); - return RunDotNetAsync(args, cancellationToken); + return RunDotNetAsync(args, cancellationToken: cancellationToken); } /// @@ -222,7 +222,7 @@ public async Task NuGetPushAllAsync(string artifactsPath, CancellationToken canc var packages = FileSystemHelper.EnumerateFiles(artifactsPath, "*.nupkg").ToArray(); if (packages.Length == 0) { - _logger.LogDebug("No .nupkg files to push."); + _reporter.Detail("No .nupkg files to push."); return; } @@ -233,7 +233,7 @@ public async Task NuGetPushAllAsync(string artifactsPath, CancellationToken canc : nugetConfig.Release; foreach (var path in packages) { - _logger.LogInformation("Pushing {Path} to {Source}...", path, target.Source); + _reporter.Info($"Pushing {path} to {target.Source}..."); await _processRunner .RunAsync( DotNetMuxer, @@ -251,6 +251,13 @@ private string ContinuousIntegrationBuildArg(bool dotnetTest) return $"{prefix}ContinuousIntegrationBuild={(_server.IsCloudBuild ? "true" : "false")}"; } - private Task RunDotNetAsync(IEnumerable args, CancellationToken cancellationToken = default) - => _processRunner.RunAsync(DotNetMuxer, args, cancellationToken: cancellationToken); + // streamOutput is false for bv-internal evaluations (e.g. the MTP probe) whose output is captured and + // inspected rather than shown to the user; user-facing verbs stream the child's output live. + private Task RunDotNetAsync(IEnumerable args, bool streamOutput = true, CancellationToken cancellationToken = default) + => _processRunner.RunAsync( + DotNetMuxer, + args, + onStdout: streamOutput ? _reporter.ChildOutput : null, + onStderr: streamOutput ? _reporter.ChildError : null, + cancellationToken: cancellationToken); } diff --git a/src/Buildvana.Tool/Services/Git/GitService.cs b/src/Buildvana.Tool/Services/Git/GitService.cs index 9b110b6..55d0844 100644 --- a/src/Buildvana.Tool/Services/Git/GitService.cs +++ b/src/Buildvana.Tool/Services/Git/GitService.cs @@ -7,11 +7,11 @@ using System.IO; using System.Linq; using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; using Buildvana.Core.HomeDirectory; using CommunityToolkit.Diagnostics; using JetBrains.Annotations; using LibGit2Sharp; -using Microsoft.Extensions.Logging; using NuGet.Versioning; using GlobalSettings = Buildvana.Tool.Subcommands.GlobalSettings; @@ -23,16 +23,16 @@ namespace Buildvana.Tool.Services.Git; [PublicAPI] internal sealed class GitService : IDisposable { - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly IHomeDirectoryProvider _home; private readonly Repository _repository; - public GitService(ILogger logger, IHomeDirectoryProvider home, GlobalSettings globals) + public GitService(IReporter reporter, IHomeDirectoryProvider home, GlobalSettings globals) { - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); Guard.IsNotNull(home); Guard.IsNotNull(globals); - _logger = logger; + _reporter = reporter; _home = home; var homeDirectory = home.HomeDirectory; BuildFailedException.ThrowIfNot(Repository.IsValid(homeDirectory), $"There is no Git repository at {homeDirectory}"); @@ -180,7 +180,7 @@ public void Stage(params string[] paths) return pathInRepo; }).ToArray(); - _logger.LogDebug("Git: staging {Count} file(s)...", pathsInRepo.Length); + _reporter.Detail(string.Create(CultureInfo.InvariantCulture, $"Staging {pathsInRepo.Length} file(s)...")); Commands.Stage(_repository, pathsInRepo, new StageOptions() { IncludeIgnored = false, ExplicitPathsOptions = new() { ShouldFailOnUnmatchedPath = true } }); } @@ -208,7 +208,7 @@ public void Commit(string message, bool amend = false, bool allowEmpty = false) /// public void UndoLastCommit() { - _logger.LogInformation("Git: undoing last commit..."); + _reporter.Info("Undoing last commit..."); var previousCommit = _repository.Head.Tip.Parents.FirstOrDefault(); BuildFailedException.ThrowIfNot(previousCommit is not null, "Git: cannot reset, there is no commit to go back to."); _repository.Reset(ResetMode.Hard, previousCommit); @@ -234,13 +234,13 @@ public void Push(bool force = false) // https://stackoverflow.com/a/47295101/5753412 // https://github.com/libgit2/libgit2sharp/blob/5085a0c6173cdb2a3fde205330b327a8eb0a26c4/LibGit2Sharp.Tests/PushFixture.cs#L183-L187 // https://github.com/libgit2/libgit2sharp/issues/104#issuecomment-1553347893 - _logger.LogInformation("Git: force pushing changes to '{Remote}'...", remote); + _reporter.Info($"Force pushing changes to '{remote}'..."); var pushRefSpec = string.Format(CultureInfo.InvariantCulture, "+{0}:{0}", _repository.Head.CanonicalName); _repository.Network.Push(_repository.Network.Remotes[remote], pushRefSpec, pushOptions); } else { - _logger.LogInformation("Git: pushing changes to '{Remote}'...", remote); + _reporter.Info($"Pushing changes to '{remote}'..."); _repository.Network.Push(head, pushOptions); } } @@ -254,12 +254,12 @@ private bool TryGetOriginInfo([MaybeNullWhen(false)] out string name, [MaybeNull string? onlyRemoteName = null; string? onlyRemoteUrl = null; var isFirst = true; - _logger.LogDebug("Git: looking for origin remote..."); + _reporter.Detail("Looking for origin remote..."); foreach (var remote in _repository.Network.Remotes) { using (remote) { - _logger.LogDebug("Git: '{Name}' ({Url})", remote.Name, remote.Url); + _reporter.Detail($" '{remote.Name}' ({remote.Url})"); if (remote.Name == "origin") { originName = remote.Name; @@ -286,7 +286,7 @@ private bool TryGetOriginInfo([MaybeNullWhen(false)] out string name, [MaybeNull url = originUrl ?? onlyRemoteUrl; if (name is null || url is null) { - _logger.LogDebug("Git: origin remote not found."); + _reporter.Detail("Origin remote not found."); return false; } @@ -297,7 +297,7 @@ private bool TryGetOriginInfo([MaybeNullWhen(false)] out string name, [MaybeNull url = url[..^4]; } - _logger.LogDebug("Git: origin remote is '{Name}' ({Url})", name, url); + _reporter.Detail($"Origin remote is '{name}' ({url})"); return true; } @@ -312,24 +312,24 @@ private string FindMainBranch(string origin, string configuredMainBranch) var configuredValue = string.Empty; if (haveConfiguredMainBranch) { - _logger.LogDebug("Git: looking for main branch on remote '{Origin}' (configured value is '{Configured}')...", origin, configuredMainBranch); + _reporter.Detail($"Looking for main branch on remote '{origin}' (configured value is '{configuredMainBranch}')..."); configuredValue = $"{origin}/{configuredMainBranch}"; } else { - _logger.LogDebug("Git: looking for main branch on remote '{Origin}' (no configured value)...", origin); + _reporter.Detail($"Looking for main branch on remote '{origin}' (no configured value)..."); } foreach (var branch in _repository.Branches.Select(static x => x.FriendlyName)) { if (haveConfiguredMainBranch && branch == configuredValue) { - _logger.LogDebug("Git: '{Branch}' <-- configured value", branch); + _reporter.Detail($" '{branch}' <-- configured value"); mainBranchFound = true; } else { - _logger.LogDebug("Git: '{Branch}'", branch); + _reporter.Detail($" '{branch}'"); if (branch == mainValue) { mainFound = true; @@ -348,11 +348,11 @@ private string FindMainBranch(string origin, string configuredMainBranch) if (mainBranch is null) { - _logger.LogDebug("Git: main branch not found on remote '{Origin}'.", origin); + _reporter.Detail($"Main branch not found on remote '{origin}'."); return string.Empty; } - _logger.LogDebug("Git: main branch '{Branch}' found on remote '{Origin}'.", mainBranch, origin); + _reporter.Detail($"Main branch '{mainBranch}' found on remote '{origin}'."); return mainBranch; } } diff --git a/src/Buildvana.Tool/Services/PublicApiFiles/PublicApiFilesService.cs b/src/Buildvana.Tool/Services/PublicApiFiles/PublicApiFilesService.cs index d73ac0f..4f8f96d 100644 --- a/src/Buildvana.Tool/Services/PublicApiFiles/PublicApiFilesService.cs +++ b/src/Buildvana.Tool/Services/PublicApiFiles/PublicApiFilesService.cs @@ -6,11 +6,11 @@ using System.IO; using System.Linq; using System.Text; +using Buildvana.Core.ConsoleOutput; using Buildvana.Core.HomeDirectory; using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; using Louis.Collections; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Services.PublicApiFiles; @@ -22,17 +22,17 @@ internal sealed class PublicApiFilesService private const string RemovedPrefix = "*REMOVED*"; private readonly IHomeDirectoryProvider _home; - private readonly ILogger _logger; + private readonly IReporter _reporter; /// /// Initializes a new instance of the class. /// - public PublicApiFilesService(IHomeDirectoryProvider home, ILogger logger) + public PublicApiFilesService(IHomeDirectoryProvider home, IReporter reporter) { Guard.IsNotNull(home); - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); _home = home; - _logger = logger; + _reporter = reporter; } /// @@ -46,12 +46,12 @@ public PublicApiFilesService(IHomeDirectoryProvider home, ILogger public ApiChangeKind GetApiChangeKind() { - _logger.LogInformation("Computing API change kind according to unshipped public API files..."); + _reporter.Info("Computing API change kind according to unshipped public API files..."); var result = ApiChangeKind.None; foreach (var unshippedPath in GetAllPublicApiFilePairs().Select(pair => pair.UnshippedPath)) { var fileResult = GetApiChangeKind(unshippedPath); - _logger.LogDebug("{UnshippedPath} -> {Result}", unshippedPath, fileResult); + _reporter.Detail($"{unshippedPath} -> {fileResult}"); if (fileResult == ApiChangeKind.Breaking) { return ApiChangeKind.Breaking; @@ -72,10 +72,10 @@ public ApiChangeKind GetApiChangeKind() /// An enumeration of the modified files. public IEnumerable TransferAllPublicApisToShipped() { - _logger.LogInformation("Updating public API files..."); + _reporter.Info("Updating public API files..."); foreach (var (unshippedPath, shippedPath) in GetAllPublicApiFilePairs()) { - _logger.LogDebug("Updating {ShippedPath}...", shippedPath); + _reporter.Detail($"Updating {shippedPath}..."); if (!TransferPublicApisToShipped(unshippedPath, shippedPath)) { continue; diff --git a/src/Buildvana.Tool/Services/SelfReferenceUpdater.cs b/src/Buildvana.Tool/Services/SelfReferenceUpdater.cs index 15f7b56..50083ca 100644 --- a/src/Buildvana.Tool/Services/SelfReferenceUpdater.cs +++ b/src/Buildvana.Tool/Services/SelfReferenceUpdater.cs @@ -3,15 +3,16 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Text; using System.Text.RegularExpressions; +using Buildvana.Core.ConsoleOutput; using Buildvana.Core.HomeDirectory; using Buildvana.Core.Json; using Buildvana.Tool.Services.Versioning; using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Services; @@ -34,23 +35,23 @@ namespace Buildvana.Tool.Services; /// internal sealed class SelfReferenceUpdater { - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly IHomeDirectoryProvider _home; private readonly IJsonHelper _jsonHelper; private readonly VersionService _version; private readonly (string RelativePath, Func, bool> Update)[] _targets; public SelfReferenceUpdater( - ILogger logger, + IReporter reporter, IHomeDirectoryProvider home, IJsonHelper jsonHelper, VersionService version) { - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); Guard.IsNotNull(home); Guard.IsNotNull(jsonHelper); Guard.IsNotNull(version); - _logger = logger; + _reporter = reporter; _home = home; _jsonHelper = jsonHelper; _version = version; @@ -74,14 +75,13 @@ public IReadOnlyList UpdateReferences(string artifactsPath) var produced = DiscoverProducedPackages(artifactsPath); if (produced.Count == 0) { - _logger.LogInformation("Self-reference update: no produced packages were found in the artifacts directory."); + _reporter.Info("Self-reference update: no produced packages were found in the artifacts directory."); return []; } - _logger.LogInformation( - "Self-reference update: {Count} produced package(s) detected: {Packages}.", - produced.Count, - string.Join(", ", produced.Keys)); + _reporter.Info(string.Create( + CultureInfo.InvariantCulture, + $"Self-reference update: {produced.Count} produced package(s) detected: {string.Join(", ", produced.Keys)}.")); var modified = new List(); foreach (var (relativePath, update) in _targets) @@ -95,7 +95,7 @@ public IReadOnlyList UpdateReferences(string artifactsPath) if (update(path, produced)) { - _logger.LogInformation("Self-reference update: rewrote {RelativePath}.", relativePath); + _reporter.Info($"Self-reference update: rewrote {relativePath}."); modified.Add(path); } } @@ -118,7 +118,7 @@ private Dictionary DiscoverProducedPackages(string artifactsPath var fileName = Path.GetFileName(path); if (!fileName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) { - _logger.LogDebug("Self-reference update: skipping '{FileName}' (version does not match '{Version}').", fileName, version); + _reporter.Detail($"Self-reference update: skipping '{fileName}' (version does not match '{version}')."); continue; } @@ -245,10 +245,7 @@ private bool IsRewritable(string existing, string id, string newVersion) // Don't rewrite property references like $(SomeVersion) — they'd silently lose their indirection. if (existing.Contains("$(", StringComparison.Ordinal)) { - _logger.LogDebug( - "Self-reference update: leaving property-reference version '{Existing}' on package '{Id}' unchanged.", - existing, - id); + _reporter.Detail($"Self-reference update: leaving property-reference version '{existing}' on package '{id}' unchanged."); return false; } diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs index 9ccab8c..0a059e2 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerAdapter.cs @@ -6,12 +6,12 @@ using System.Text; using System.Threading.Tasks; using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.Configuration; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.Versioning; using CommunityToolkit.Diagnostics; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Octokit; namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; @@ -22,7 +22,7 @@ namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; internal sealed class GitHubServerAdapter : ServerAdapter { private readonly IServiceProvider _services; - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly VersionService _version; private readonly GitService _git; @@ -31,7 +31,7 @@ internal sealed class GitHubServerAdapter : ServerAdapter private GitHubServerAdapter(IServiceProvider services) { _services = services; - _logger = services.GetRequiredService>(); + _reporter = services.GetRequiredService(); _version = services.GetRequiredService(); _git = services.GetRequiredService(); BuildFailedException.ThrowIfNot(GitUrlInfo.TryCreate(_git.OriginUrl, out var originInfo), $"Couldn't get information from origin URL '{_git.OriginUrl}'."); @@ -100,7 +100,7 @@ public static void SetActionsStepOutput(string name, string value) /// public override async Task IsPrivateRepositoryAsync() { - _logger.LogInformation("Fetching repository information..."); + _reporter.Info("Fetching repository information..."); var client = CreateGitHubClient(); var repository = await client.Repository.Get(RepositoryOwner, RepositoryName).ConfigureAwait(false); return repository.Private; @@ -137,7 +137,7 @@ public override async Task CreateReleaseAsync() // Create the release as a draft first, so if the token has no permissions we can bail out early var tag = _version.CurrentStr; var client = CreateGitHubClient(); - _logger.LogInformation("Creating a provisional draft release..."); + _reporter.Info("Creating a provisional draft release..."); var newRelease = new NewRelease(tag) { Name = $"{tag} [provisional]", @@ -163,7 +163,7 @@ public async Task PublishReleaseAsync(Release release, string targetCommitish) Guard.IsNotNullOrEmpty(targetCommitish); var tag = _version.CurrentStr; var client = CreateGitHubClient(); - _logger.LogInformation("Generating release notes for {Tag}...", tag); + _reporter.Info($"Generating release notes for {tag}..."); var releaseNotesRequest = new GenerateReleaseNotesRequest(tag) { TargetCommitish = targetCommitish, @@ -173,7 +173,7 @@ public async Task PublishReleaseAsync(Release release, string targetCommitish) var body = $"We also have a [human-curated changelog]({GetFileUrl("CHANGELOG.md", _git.MainBranch)}).\n\n---\n\n" + generateNotesResponse.Body; - _logger.LogInformation("Publishing the previously created release as {Tag} (target {TargetCommitish})...", tag, targetCommitish); + _reporter.Info($"Publishing the previously created release as {tag} (target {targetCommitish})..."); var update = release.ToUpdate(); update.TagName = tag; update.Name = tag; @@ -194,7 +194,7 @@ public async Task PublishReleaseAsync(Release release, string targetCommitish) public async Task DeleteReleaseAsync(Release release, string? tagName) { Guard.IsNotNull(release); - _logger.LogInformation("Deleting the previously created release..."); + _reporter.Info("Deleting the previously created release..."); var client = CreateGitHubClient(); await client.Repository.Release.Delete(RepositoryOwner, RepositoryName, release.Id).ConfigureAwait(false); if (string.IsNullOrEmpty(tagName)) @@ -203,18 +203,18 @@ public async Task DeleteReleaseAsync(Release release, string? tagName) } var reference = "refs/tags/" + tagName; - _logger.LogInformation("Looking for reference '{Reference}' in GitHub repository...", reference); + _reporter.Info($"Looking for reference '{reference}' in GitHub repository..."); try { _ = await client.Git.Reference.Get(RepositoryOwner, RepositoryName, reference).ConfigureAwait(false); } catch (NotFoundException) { - _logger.LogInformation("Reference '{Reference}' not found in GitHub repository.", reference); + _reporter.Info($"Reference '{reference}' not found in GitHub repository."); return; } - _logger.LogInformation("Deleting reference '{Reference}' in GitHub repository...", reference); + _reporter.Info($"Deleting reference '{reference}' in GitHub repository..."); await client.Git.Reference.Delete(RepositoryOwner, RepositoryName, reference).ConfigureAwait(false); } @@ -229,7 +229,7 @@ public async Task DeleteReleaseAsync(Release release, string? tagName) public async Task UploadReleaseAssetAsync(Release release, string path, string mimeType, string description) { var client = CreateGitHubClient(); - _logger.LogDebug("Uploading asset {Path}...", path); + _reporter.Detail($"Uploading asset {path}..."); ReleaseAsset asset; var assetContents = File.OpenRead(path); await using (assetContents.ConfigureAwait(false)) @@ -246,14 +246,14 @@ public async Task UploadReleaseAssetAsync(Release release, string path, string m if (!string.IsNullOrEmpty(description)) { - _logger.LogDebug("Updating asset label..."); + _reporter.Detail("Updating asset label..."); var update = asset.ToUpdate(); update.Label = description; _ = await client.Repository.Release.EditAsset(RepositoryOwner, RepositoryName, asset.Id, update).ConfigureAwait(false); } else { - _logger.LogDebug("Skipping label update: asset has no description."); + _reporter.Detail("Skipping label update: asset has no description."); } } diff --git a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs index 469c95b..685fd7c 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/GitHubServerRelease.cs @@ -3,12 +3,13 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Threading.Tasks; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.Services.Versioning; using CommunityToolkit.Diagnostics; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Octokit; namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; @@ -19,7 +20,7 @@ namespace Buildvana.Tool.Services.ServerAdapters.Internal.GitHub; internal sealed class GitHubServerRelease : ServerRelease { private readonly GitHubServerAdapter _server; - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly VersionService _version; private readonly Release _gitHubRelease; @@ -33,7 +34,7 @@ private GitHubServerRelease(GitHubServerAdapter server, IServiceProvider service Guard.IsNotNull(gitHubRelease); _server = server; - _logger = services.GetRequiredService>(); + _reporter = services.GetRequiredService(); _version = services.GetRequiredService(); _gitHubRelease = gitHubRelease; @@ -66,18 +67,15 @@ protected override async Task DoPublishAsync(IReadOnlyList assets) foreach (var asset in assets) { i++; - _logger.LogInformation( - "Uploading asset {Index} of {Count}: {Filename} ({Description})...", - i, - assetCount, - Path.GetFileName(asset.Path), - asset.Description); + _reporter.Info(string.Create( + CultureInfo.InvariantCulture, + $"Uploading asset {i} of {assetCount}: {Path.GetFileName(asset.Path)} ({asset.Description})...")); await _server.UploadReleaseAssetAsync(_gitHubRelease, asset.Path, asset.MimeType, asset.Description).ConfigureAwait(false); } } else { - _logger.LogInformation("Asset upload skipped: no release assets defined."); + _reporter.Info("Asset upload skipped: no release assets defined."); } await _server.PublishReleaseAsync(_gitHubRelease, ReleaseCommitSha).ConfigureAwait(false); diff --git a/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs b/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs index bece2ed..f206c86 100644 --- a/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs +++ b/src/Buildvana.Tool/Services/ServerAdapters/ServerRelease.cs @@ -5,12 +5,12 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using Buildvana.Core.ConsoleOutput; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.Versioning; using CommunityToolkit.Diagnostics; using Louis.Diagnostics; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Services.ServerAdapters; @@ -19,7 +19,7 @@ namespace Buildvana.Tool.Services.ServerAdapters; /// internal abstract partial class ServerRelease : IAsyncDisposable { - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly GitService _git; private readonly VersionService _version; private readonly Stack> _rollbackActions = new(); @@ -35,7 +35,7 @@ private protected ServerRelease(IServiceProvider services) { Guard.IsNotNull(services); - _logger = services.GetRequiredService().CreateLogger(GetType()); + _reporter = services.GetRequiredService(); _git = services.GetRequiredService(); _version = services.GetRequiredService(); } @@ -77,12 +77,12 @@ public void EnsureReleaseCommit() return; } - _logger.LogInformation("Creating release commit..."); + _reporter.Info("Creating release commit..."); _git.Commit("Prepare release [skip ci]", allowEmpty: true); // Git height has changed _version.Update(); - _logger.LogInformation("Version changed to {Version}", _version.CurrentStr); + _reporter.Info($"Version changed to {_version.CurrentStr}"); _git.Commit($"Prepare release {_version.CurrentStr} [skip ci]", amend: true, allowEmpty: true); _repositoryUpdated = true; _releaseCommitSha = _git.HeadSha; @@ -125,7 +125,7 @@ public void UpdateRepository(params string[] files) EnsureReleaseCommit(); _git.Stage(files); - _logger.LogInformation("Amending release commit..."); + _reporter.Info("Amending release commit..."); _git.Commit($"Prepare release {_version.CurrentStr} [skip ci]", amend: true, allowEmpty: true); _releaseCommitSha = _git.HeadSha; } @@ -156,7 +156,7 @@ public void AddPostReleaseCommit(string message, params string[] files) EnsureReleaseCommit(); _git.Stage(files); - _logger.LogInformation("Committing post-release changed files..."); + _reporter.Info("Committing post-release changed files..."); _git.Commit(message); _postReleaseCommits++; @@ -171,7 +171,7 @@ public void PushUpdates() if (!_repositoryUpdated) { - _logger.LogInformation("Repository unchanged, no commit to push."); + _reporter.Info("Repository unchanged, no commit to push."); return; } @@ -228,12 +228,7 @@ public async ValueTask DisposeAsync() } catch (Exception ex) when (!ex.IsCriticalError()) { - _logger.LogWarning( - "{ExceptionType} in release rollback action: {Message}{NewLine}{StackTrace}", - ex.GetType().Name, - ex.Message, - Environment.NewLine, - ex.StackTrace); + _reporter.Warning($"{ex.GetType().Name} in release rollback action: {ex.Message}{Environment.NewLine}{ex.StackTrace}"); } } diff --git a/src/Buildvana.Tool/Services/Versioning/VersionService.cs b/src/Buildvana.Tool/Services/Versioning/VersionService.cs index da7cc9c..4228b6f 100644 --- a/src/Buildvana.Tool/Services/Versioning/VersionService.cs +++ b/src/Buildvana.Tool/Services/Versioning/VersionService.cs @@ -2,12 +2,12 @@ // See the LICENSE file in the project root for full license information. using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; using Buildvana.Core.Json; using Buildvana.Core.Process; using Buildvana.Tool.Services.Git; using Buildvana.Tool.Services.PublicApiFiles; using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.Logging; using NuGet.Versioning; namespace Buildvana.Tool.Services.Versioning; @@ -17,7 +17,7 @@ namespace Buildvana.Tool.Services.Versioning; /// internal sealed class VersionService { - private readonly ILogger _logger; + private readonly IReporter _reporter; private readonly IJsonHelper _jsonHelper; private readonly IProcessRunner _processRunner; private readonly PublicApiFilesService _publicApiFiles; @@ -26,18 +26,18 @@ internal sealed class VersionService /// Initializes a new instance of the class. /// public VersionService( - ILogger logger, + IReporter reporter, IJsonHelper jsonHelper, IProcessRunner processRunner, GitService git, PublicApiFilesService publicApiFiles) { - Guard.IsNotNull(logger); + Guard.IsNotNull(reporter); Guard.IsNotNull(jsonHelper); Guard.IsNotNull(processRunner); Guard.IsNotNull(git); Guard.IsNotNull(publicApiFiles); - _logger = logger; + _reporter = reporter; _jsonHelper = jsonHelper; _processRunner = processRunner; _publicApiFiles = publicApiFiles; @@ -121,14 +121,12 @@ public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedCha : Current.Major > LatestStable.Major ? VersionIncrement.Major : Current.Minor > LatestStable.Minor ? VersionIncrement.Minor : VersionIncrement.None; - _logger.LogInformation("Current version increment: {Increment}", currentVersionIncrement); + _reporter.Info($"Current version increment: {currentVersionIncrement}"); // Determine the kind of change in public API var publicApiChangeKind = checkPublicApiFiles ? _publicApiFiles.GetApiChangeKind() : ApiChangeKind.None; - _logger.LogInformation( - "Public API change kind: {Kind}{NotCheckedSuffix}", - publicApiChangeKind, - checkPublicApiFiles ? string.Empty : " (not checked)"); + var notCheckedSuffix = checkPublicApiFiles ? string.Empty : " (not checked)"; + _reporter.Info($"Public API change kind: {publicApiChangeKind}{notCheckedSuffix}"); // Determine the version increment required by SemVer rules // When the major version is 0, "anything MAY change" according to SemVer; @@ -139,16 +137,16 @@ public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedCha ApiChangeKind.Additive => isMajorVersionZero ? VersionIncrement.None : VersionIncrement.Minor, _ => VersionIncrement.None, }; - _logger.LogInformation("Required version increment according to Semantic Versioning rules: {Increment}", semanticVersionIncrement); + _reporter.Info($"Required version increment according to Semantic Versioning rules: {semanticVersionIncrement}"); // Determine the requested version increment, if any. - _logger.LogInformation("Requested version spec change: {Change}", requestedChange); + _reporter.Info($"Requested version spec change: {requestedChange}"); var requestedVersionIncrement = requestedChange switch { VersionSpecChange.Major => VersionIncrement.Major, VersionSpecChange.Minor => VersionIncrement.Minor, _ => VersionIncrement.None, }; - _logger.LogInformation("Requested version increment: {Increment}.", requestedVersionIncrement); + _reporter.Info($"Requested version increment: {requestedVersionIncrement}."); // Adjust requested version increment to follow SemVer rules if (semanticVersionIncrement > requestedVersionIncrement) @@ -158,7 +156,7 @@ public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedCha // Determine the kind of version increment actually required var actualVersionIncrement = requestedVersionIncrement > currentVersionIncrement ? requestedVersionIncrement : VersionIncrement.None; - _logger.LogInformation("Required version increment with respect to current version: {Increment}", actualVersionIncrement); + _reporter.Info($"Required version increment with respect to current version: {actualVersionIncrement}"); // Determine the actual version spec change to apply: // - forget any increment-related change (already accounted for via requestedVersionIncrement) @@ -172,7 +170,7 @@ public VersionSpecChange ComputeVersionSpecChange(VersionSpecChange requestedCha VersionIncrement.Minor => VersionSpecChange.Minor, _ => actualChange, }; - _logger.LogInformation("Actual version spec change: {Change}.", actualChange); + _reporter.Info($"Actual version spec change: {actualChange}."); return actualChange; } diff --git a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs index 82d866c..0cc8d11 100644 --- a/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs +++ b/src/Buildvana.Tool/Subcommands/ReleaseCommand.cs @@ -3,11 +3,13 @@ using System; using System.ComponentModel; +using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Buildvana.Core; +using Buildvana.Core.ConsoleOutput; using Buildvana.Core.HomeDirectory; using Buildvana.Core.Json; using Buildvana.Tool.Build; @@ -20,7 +22,6 @@ using Buildvana.Tool.Services.Versioning; using Buildvana.Tool.Utilities; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Subcommands; @@ -30,13 +31,15 @@ internal sealed class ReleaseCommand(IServiceProvider services, ReleaseSettings { public async Task ExecuteAsync(CancellationToken cancellationToken) { + var reporter = services.GetRequiredService(); + using var activity = reporter.BeginActivity("Release"); + var configuration = settings.ResolveConfiguration(); var artifactsPath = Path.Combine(CommonPaths.AllArtifacts, configuration); // Verification pass (Clean→Test), mirroring today's [IsDependentOn(TestTask)] chain. await pipeline.RunThroughAsync(BuildStep.Test, configuration, cancellationToken).ConfigureAwait(false); - var logger = services.GetRequiredService().CreateLogger("Release"); var home = services.GetRequiredService(); var jsonHelper = services.GetRequiredService(); var server = services.GetRequiredService(); @@ -55,19 +58,19 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) // Ensure that the CI bot identity is used for commits, if not already set. git.CommitterIdentity ??= server.CIBotIdentity ?? throw new BuildFailedException("Cannot determine a committer identity for release commits. Configure git config user.name/user.email before running this task."); - logger.LogInformation("Using committer identity: {Name} <{Email}>", git.CommitterIdentity.Name, git.CommitterIdentity.Email); + reporter.Info($"Using committer identity: {git.CommitterIdentity.Name} <{git.CommitterIdentity.Email}>"); // Set fallback Git credentials if the server adapter can provide them. var pushUsername = server.PushUsername; var pushPassword = server.PushPassword; if (pushUsername is not null && pushPassword is not null) { - logger.LogInformation("Fallback push credentials provided by the server adapter (protocol username: '{Username}').", pushUsername); + reporter.Info($"Fallback push credentials provided by the server adapter (protocol username: '{pushUsername}')."); git.PushCredentialsFallback = new(pushUsername, pushPassword); } else { - logger.LogWarning("No push credentials provided by the server adapter. Push operations may fail if the repository is not already authenticated."); + reporter.Warning("No push credentials provided by the server adapter. Push operations may fail if the repository is not already authenticated."); } // Perform an initial versioning consistency check. @@ -91,20 +94,20 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) var previousVersionSpec = versionFile.VersionSpec; if (versionFile.ApplyVersionSpecChange(versionSpecChange)) { - logger.LogInformation("Version spec changed from {Previous} to {New}.", previousVersionSpec, versionFile.VersionSpec); + reporter.Info($"Version spec changed from {previousVersionSpec} to {versionFile.VersionSpec}."); versionFile.Save(); release.UpdateRepository(versionFile.Path); } else { - logger.LogInformation("Version spec not changed."); + reporter.Info("Version spec not changed."); } } // Update public API files only when releasing a stable version if (version.IsPrerelease) { - logger.LogInformation("Public API update skipped: not needed on prerelease."); + reporter.Info("Public API update skipped: not needed on prerelease."); } else { @@ -112,13 +115,13 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) switch (modified.Length) { case 0: - logger.LogInformation("No public API files were modified."); + reporter.Info("No public API files were modified."); break; case 1: - logger.LogInformation("1 public API file was modified."); + reporter.Info("1 public API file was modified."); break; default: - logger.LogInformation("{Count} public API files were modified.", modified.Length); + reporter.Info(string.Create(CultureInfo.InvariantCulture, $"{modified.Length} public API files were modified.")); break; } @@ -132,7 +135,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) var changelogUpdated = false; if (!changelog.Exists) { - logger.LogInformation("Changelog update skipped: {Path} not found.", ChangelogService.FileName); + reporter.Info($"Changelog update skipped: {ChangelogService.FileName} not found."); } else if (!version.IsPrerelease || settings.ResolveUnstableChangelog()) { @@ -142,11 +145,11 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) changelog.HasUnreleasedChanges(), "Changelog check failed: the \"Unreleased changes\" section is empty or only contains sub-section headings."); - logger.LogInformation("Changelog check successful: the \"Unreleased changes\" section is not empty."); + reporter.Info("Changelog check successful: the \"Unreleased changes\" section is not empty."); } else { - logger.LogInformation("Changelog check skipped: option 'requireChangelog' is false."); + reporter.Info("Changelog check skipped: option 'requireChangelog' is false."); } // Update the changelog and commit the change before building. @@ -157,7 +160,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) } else { - logger.LogInformation("Changelog update skipped: not needed on prerelease."); + reporter.Info("Changelog update skipped: not needed on prerelease."); } // At this point we know what the actual published version will be. @@ -180,7 +183,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) } else { - logger.LogInformation("Changelog section title update skipped: changelog has not been updated."); + reporter.Info("Changelog section title update skipped: changelog has not been updated."); } // Update in-tree references to packages produced by this release (dogfooding). @@ -195,13 +198,13 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) switch (selfReferenceUpdates.Count) { case 0: - logger.LogInformation("No self-referenced files were modified."); + reporter.Info("No self-referenced files were modified."); break; case 1: - logger.LogInformation("1 self-referenced file was modified."); + reporter.Info("1 self-referenced file was modified."); break; default: - logger.LogInformation("{Count} self-referenced files were modified.", selfReferenceUpdates.Count); + reporter.Info(string.Create(CultureInfo.InvariantCulture, $"{selfReferenceUpdates.Count} self-referenced files were modified.")); break; } @@ -214,7 +217,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) } else { - logger.LogInformation("Self-reference update skipped: option 'dogfood' is false."); + reporter.Info("Self-reference update skipped: option 'dogfood' is false."); } release.PushUpdates(); @@ -223,10 +226,10 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) await dotnet.NuGetPushAllAsync(artifactsPath, cancellationToken).ConfigureAwait(false); // Gather build assets from Buildvana.Sdk release asset lists - logger.LogInformation("Reading release asset lists..."); + reporter.Info("Reading release asset lists..."); foreach (var path in FileSystemHelper.EnumerateFiles(artifactsPath, "*.assets.txt")) { - logger.LogDebug("Reading release asset list {Path}...", path); + reporter.Detail($"Reading release asset list {path}..."); var i = 0; await foreach (var line in File.ReadLinesAsync(path, cancellationToken).ConfigureAwait(false)) { @@ -234,13 +237,13 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) var parts = line.Split('\t'); if (parts.Length != 3) { - logger.LogWarning("Release asset list {Path}, line #{LineNumber}: invalid line '{Line}'", path, i, line); + reporter.Warning(string.Create(CultureInfo.InvariantCulture, $"Release asset list {path}, line #{i}: invalid line '{line}'")); continue; } if (!File.Exists(parts[0])) { - logger.LogWarning("Release asset list {Path}, line #{LineNumber}: asset not found '{Asset}'", path, i, parts[0]); + reporter.Warning(string.Create(CultureInfo.InvariantCulture, $"Release asset list {path}, line #{i}: asset not found '{parts[0]}'")); continue; } @@ -264,17 +267,17 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) { if (version.IsPrerelease) { - logger.LogInformation("Documentation generation skipped: not needed on prerelease."); + reporter.Info("Documentation generation skipped: not needed on prerelease."); } else if (git.CurrentBranch != git.MainBranch) { - logger.LogInformation("Documentation generation skipped: releasing from '{Current}', not '{Main}'.", git.CurrentBranch, git.MainBranch); + reporter.Info($"Documentation generation skipped: releasing from '{git.CurrentBranch}', not '{git.MainBranch}'."); } else { - logger.LogInformation("Generating documentation web pages..."); + reporter.Info("Generating documentation web pages..."); await docfx.GenerateSiteAsync().ConfigureAwait(false); - logger.LogInformation("Generating documentation PDF files..."); + reporter.Info("Generating documentation PDF files..."); await docfx.GeneratePdfsAsync().ConfigureAwait(false); } } @@ -283,6 +286,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) await release.PublishAsync().ConfigureAwait(false); } + activity.Complete(); return 0; } } diff --git a/src/Buildvana.Tool/Utilities/FileSystemHelper.cs b/src/Buildvana.Tool/Utilities/FileSystemHelper.cs index e97770d..c22647b 100644 --- a/src/Buildvana.Tool/Utilities/FileSystemHelper.cs +++ b/src/Buildvana.Tool/Utilities/FileSystemHelper.cs @@ -5,10 +5,10 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Buildvana.Core.ConsoleOutput; using CommunityToolkit.Diagnostics; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.FileSystemGlobbing.Abstractions; -using Microsoft.Extensions.Logging; namespace Buildvana.Tool.Utilities; @@ -39,18 +39,18 @@ public static bool FileExists(string path) /// Recursively delete a directory and all its contents. No-op if the directory does not exist. /// /// The directory to delete. - /// Optional logger. When provided, logs Information on actual deletion - /// and Debug when the directory does not exist and is therefore skipped. - public static void DeleteDirectory(string path, ILogger? logger = null) + /// Optional reporter. When provided, reports at Info on actual deletion + /// and at Detail when the directory does not exist and is therefore skipped. + public static void DeleteDirectory(string path, IReporter? reporter = null) { Guard.IsNotNull(path); if (!Directory.Exists(path)) { - logger?.LogDebug("Skipping non-existent directory: {Path}", path); + reporter?.Detail($"Skipping non-existent directory: {path}"); return; } - logger?.LogInformation("Deleting directory: {Path}", path); + reporter?.Info($"Deleting directory: {path}"); Directory.Delete(path, recursive: true); } From dfd0e5befa1ea9a2decceb5f0d44d59bdfa1157f Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 29 May 2026 02:02:58 +0200 Subject: [PATCH 04/15] Fix code style --- src/Buildvana.Tool/Services/DotNetService.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Buildvana.Tool/Services/DotNetService.cs b/src/Buildvana.Tool/Services/DotNetService.cs index 712e747..3f8dd9a 100644 --- a/src/Buildvana.Tool/Services/DotNetService.cs +++ b/src/Buildvana.Tool/Services/DotNetService.cs @@ -253,7 +253,10 @@ private string ContinuousIntegrationBuildArg(bool dotnetTest) // streamOutput is false for bv-internal evaluations (e.g. the MTP probe) whose output is captured and // inspected rather than shown to the user; user-facing verbs stream the child's output live. - private Task RunDotNetAsync(IEnumerable args, bool streamOutput = true, CancellationToken cancellationToken = default) + private Task RunDotNetAsync( + IEnumerable args, + bool streamOutput = true, + CancellationToken cancellationToken = default) => _processRunner.RunAsync( DotNetMuxer, args, From 686387f22ae189aefaf1062f53678464925263b4 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 29 May 2026 02:03:52 +0200 Subject: [PATCH 05/15] Add no-color.org links in changelog and on NO_COLOR check --- CHANGELOG.md | 2 +- src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 289d8d9..bb1dd14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `diagnostic` (or `diag`) Cake verbosity values (e.g., `verbose`) are no longer accepted. -- `bv` no longer prefixes its console output with a log level and a class-name category (e.g. `info: Buildvana.Tool.Services.DotNetService: ...`). Messages now render as clean, color-coded lines: errors in red and warnings in yellow, each line tagged with a short level label (`error:`/`warning:`/`info:`/`detail:`/`trace:`). In addition, `dotnet`/MSBuild output is now streamed through live (standard output to `bv`'s standard output, standard error to its standard error) instead of being hidden unless the build fails; on failure, the first and last lines of the captured output are still included in the error message. Verbosity behavior is unchanged (`--verbosity quiet|minimal|normal|detailed|diagnostic`), as is the handling of `--color`/`--no-color` (with the `NO_COLOR` environment variable now honored as well). +- `bv` no longer prefixes its console output with a log level and a class-name category (e.g. `info: Buildvana.Tool.Services.DotNetService: ...`). Messages now render as clean, color-coded lines: errors in red and warnings in yellow, each line tagged with a short level label (`error:`/`warning:`/`info:`/`detail:`/`trace:`). In addition, `dotnet`/MSBuild output is now streamed through live (standard output to `bv`'s standard output, standard error to its standard error) instead of being hidden unless the build fails; on failure, the first and last lines of the captured output are still included in the error message. Verbosity behavior is unchanged (`--verbosity quiet|minimal|normal|detailed|diagnostic`), as is the handling of `--color`/`--no-color` (with the [`NO_COLOR` environment variable](https://no-color.org) now honored as well). - **BREAKING CHANGE**: `bv restore`, `bv build`, `bv test`, and `bv pack` forward extra command-line arguments to the underlying `dotnet` invocation(s) only after a `--` separator: everything after the first `--` is passed through verbatim, in the order given, and `bv` no longer parses or validates it. A non-global, option-looking token _before_ `--` is now an error that points you at the separator. Malformed or unknown forwarded arguments produce an error from `dotnet` (or, for `bv test`, from the Microsoft.Testing.Platform test application) rather than from `bv`. Previously only `-p:`/`/p:` MSBuild properties were forwarded. `bv` also always forwards `--nologo` and its resolved `--verbosity` (default `normal`) to those invocations. - `bv build -- -m:8 -v:minimal` forwards `-m:8 -v:minimal` to `dotnet build`. - `bv test -- --report-trx` reaches the test application. diff --git a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs index 4ee8fec..f64cf49 100644 --- a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs +++ b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs @@ -112,6 +112,14 @@ public void ChildError(string line) } } + /// + /// Determines whether the NO_COLOR environment variable is set. + /// + /// if the NO_COLOR environment variable is set; otherwise, . + /// + /// The NO_COLOR environment variable is a widely-adopted convention for opting out of color in command-line applications. + /// See https://no-color.org for more information. + /// private static bool IsNoColorSet() => Environment.GetEnvironmentVariable("NO_COLOR") is { Length: > 0 }; private static (ConsoleColor? Color, string Word) StyleFor(MessageLevel level) => level switch From f140cfc21fa6b80c03944279ad8935ffeb1ce979 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 29 May 2026 02:09:13 +0200 Subject: [PATCH 06/15] Output activity scope starting message at Info level --- src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs index f64cf49..0bc2176 100644 --- a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs +++ b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs @@ -168,7 +168,7 @@ private void EndActivity(ActivityScope scope, bool completed) } // No outcome line unless the activity was explicitly completed (e.g. the work threw before Complete). - if (completed && this.IsEnabled(MessageLevel.Detail)) + if (completed && this.IsEnabled(MessageLevel.Info)) { Console.WriteLine(FormatActivityLine(scope.Depth, scope.Title, scope.Elapsed)); } From 98cb9de7d967650779d2c3863a2219c7e5279d8c Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Fri, 29 May 2026 02:21:04 +0200 Subject: [PATCH 07/15] Add ": starting..." to the activity scope starting message --- src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs index 0bc2176..4626eb4 100644 --- a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs +++ b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs @@ -136,7 +136,7 @@ public void ChildError(string line) private static string FormatActivityLine(int depth, string title, TimeSpan? elapsed) => elapsed is { } e ? string.Format(CultureInfo.InvariantCulture, "[{0}] {1}: done ({2:F1}s)", depth, title, e.TotalSeconds) - : string.Format(CultureInfo.InvariantCulture, "[{0}] {1}", depth, title); + : string.Format(CultureInfo.InvariantCulture, "[{0}] {1}: starting...", depth, title); private void WriteLeveledLine(MessageLevel level, string message) { From 3c9e4fa24b46740e560030915f69b6b8352085d4 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Sat, 30 May 2026 10:42:50 +0200 Subject: [PATCH 08/15] Add an optional outcome message to activity scopes It's common to output an outcome-describing message at Info level immediately before closing an activity scope. This allows that message to be incorporated into the scope's closing line, reducing output clutter at Normal verbosity level and below. --- .../ConsoleOutput/IActivityScope.cs | 3 ++- .../ConsoleOutput/NullActivityScope.cs | 2 +- .../ConsoleReporter.ActivityScope.cs | 8 +++++++- src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs | 8 ++++---- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/IActivityScope.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/IActivityScope.cs index 6894cd7..4979dca 100644 --- a/src/Buildvana.Core.Abstractions/ConsoleOutput/IActivityScope.cs +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/IActivityScope.cs @@ -20,5 +20,6 @@ public interface IActivityScope : IDisposable /// /// Marks the activity as successfully completed, so that disposing the scope reports its outcome. /// - void Complete(); + /// An optional message describing the outcome of the activity. + void Complete(string? outcomeMessage = null); } diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/NullActivityScope.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullActivityScope.cs index 9451224..75d0248 100644 --- a/src/Buildvana.Core.Abstractions/ConsoleOutput/NullActivityScope.cs +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullActivityScope.cs @@ -19,7 +19,7 @@ private NullActivityScope() public static NullActivityScope Instance { get; } = new(); /// - public void Complete() + public void Complete(string? outcomeMessage = null) { } diff --git a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.ActivityScope.cs b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.ActivityScope.cs index 4da6a4a..9452809 100644 --- a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.ActivityScope.cs +++ b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.ActivityScope.cs @@ -29,7 +29,13 @@ public ActivityScope(ConsoleReporter reporter, string title, int depth) public TimeSpan Elapsed => Stopwatch.GetElapsedTime(_startTimestamp); - public void Complete() => _completed = true; + public string? OutcomeMessage { get; private set; } + + public void Complete(string? outcomeMessage = null) + { + _completed = true; + OutcomeMessage = outcomeMessage; + } public void Dispose() { diff --git a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs index 4626eb4..a05ff05 100644 --- a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs +++ b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs @@ -71,7 +71,7 @@ public IActivityScope BeginActivity(string title) _activityStack.Push(scope); if (this.IsEnabled(MessageLevel.Info)) { - Console.WriteLine(FormatActivityLine(depth, title, elapsed: null)); + Console.WriteLine(FormatActivityLine(depth, title, elapsed: null, outcomeMessage: null)); } return scope; @@ -133,9 +133,9 @@ public void ChildError(string line) }; // Activity header/outcome lines are label-less; the leading "[depth]" conveys nesting without indentation. - private static string FormatActivityLine(int depth, string title, TimeSpan? elapsed) + private static string FormatActivityLine(int depth, string title, TimeSpan? elapsed, string? outcomeMessage) => elapsed is { } e - ? string.Format(CultureInfo.InvariantCulture, "[{0}] {1}: done ({2:F1}s)", depth, title, e.TotalSeconds) + ? string.Format(CultureInfo.InvariantCulture, "[{0}] {1}: done ({2:F1}s){3}{4}", depth, title, e.TotalSeconds, outcomeMessage is null ? string.Empty : " - ", outcomeMessage) : string.Format(CultureInfo.InvariantCulture, "[{0}] {1}: starting...", depth, title); private void WriteLeveledLine(MessageLevel level, string message) @@ -170,7 +170,7 @@ private void EndActivity(ActivityScope scope, bool completed) // No outcome line unless the activity was explicitly completed (e.g. the work threw before Complete). if (completed && this.IsEnabled(MessageLevel.Info)) { - Console.WriteLine(FormatActivityLine(scope.Depth, scope.Title, scope.Elapsed)); + Console.WriteLine(FormatActivityLine(scope.Depth, scope.Title, scope.Elapsed, scope.OutcomeMessage)); } } } From cba3d6760044981325e5dda71b733364ad044615 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Sat, 30 May 2026 10:46:31 +0200 Subject: [PATCH 09/15] Make it possible to stream a child process output regardless of verbosity This is useful for processes like `dotnet` whose verbosity we set equal to ours. --- .../ConsoleOutput/IReporter.cs | 8 ++++++-- .../ConsoleOutput/NullReporter.cs | 4 ++-- .../ConsoleReporter.cs | 14 ++++++++------ src/Buildvana.Tool/Services/DotNetService.cs | 4 ++-- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs index 1669058..3a7f56d 100644 --- a/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs @@ -36,12 +36,16 @@ public interface IReporter /// Used to stream a spawned process's standard output through to this process's standard output. /// /// The line of child-process standard output to write. - void ChildOutput(string line); + /// The verbosity level at which the line should be written. + /// If `null`, the line is written regardless of the current verbosity. + void ChildOutput(string line, Verbosity? verbosity); /// /// Writes a line from a child process's standard error verbatim: no level label, no color, no category. /// Used to stream a spawned process's standard error through to this process's standard error. /// /// The line of child-process standard error to write. - void ChildError(string line); + /// The verbosity level at which the line should be written. + /// If `null`, the line is written regardless of the current verbosity. + void ChildError(string line, Verbosity? verbosity); } diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs index 9952eeb..6b79b5b 100644 --- a/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs @@ -30,12 +30,12 @@ public void Report(MessageLevel level, string message) public IActivityScope BeginActivity(string title) => NullActivityScope.Instance; /// - public void ChildOutput(string line) + public void ChildOutput(string line, Verbosity? verbosity) { } /// - public void ChildError(string line) + public void ChildError(string line, Verbosity? verbosity) { } } diff --git a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs index a05ff05..296bebe 100644 --- a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs +++ b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs @@ -79,12 +79,13 @@ public IActivityScope BeginActivity(string title) } /// - public void ChildOutput(string line) + public void ChildOutput(string line, Verbosity? verbosity) { Guard.IsNotNull(line); - // Quiet swallows child output; the process runner's head/tail buffer still provides a failure tail. - if (Verbosity == Verbosity.Quiet) + // If a verbosity is specified, respect it; otherwise, write regardless of current verbosity. + // This is useful for child processes whose verbosity we control, e.g., `dotnet`. + if (verbosity is not null && (int)verbosity <= (int)Verbosity) { return; } @@ -96,12 +97,13 @@ public void ChildOutput(string line) } /// - public void ChildError(string line) + public void ChildError(string line, Verbosity? verbosity) { Guard.IsNotNull(line); - // As with ChildOutput, Quiet swallows the live stream and relies on the head/tail failure tail. - if (Verbosity == Verbosity.Quiet) + // If a verbosity is specified, respect it; otherwise, write regardless of current verbosity. + // This is useful for child processes whose verbosity we control, e.g., `dotnet`. + if (verbosity is not null && (int)verbosity <= (int)Verbosity) { return; } diff --git a/src/Buildvana.Tool/Services/DotNetService.cs b/src/Buildvana.Tool/Services/DotNetService.cs index 3f8dd9a..ddc08d5 100644 --- a/src/Buildvana.Tool/Services/DotNetService.cs +++ b/src/Buildvana.Tool/Services/DotNetService.cs @@ -260,7 +260,7 @@ private Task RunDotNetAsync( => _processRunner.RunAsync( DotNetMuxer, args, - onStdout: streamOutput ? _reporter.ChildOutput : null, - onStderr: streamOutput ? _reporter.ChildError : null, + onStdout: streamOutput ? (x) => _reporter.ChildOutput(x, null) : null, + onStderr: streamOutput ? (x) => _reporter.ChildError(x, null) : null, cancellationToken: cancellationToken); } From faf65a44c16df38b894e6d4a9d8874b08b3409a9 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Sat, 30 May 2026 13:55:41 +0200 Subject: [PATCH 10/15] Add support for lazy service resolution in DI --- .../DependencyInjection/LazyResolver`1.cs | 17 ++++++++++++++ .../ServiceCollectionExtensions.cs | 22 +++++++++++++++++++ src/Buildvana.Tool/Program.cs | 2 ++ 3 files changed, 41 insertions(+) create mode 100644 src/Buildvana.Tool/Infrastructure/DependencyInjection/LazyResolver`1.cs create mode 100644 src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs diff --git a/src/Buildvana.Tool/Infrastructure/DependencyInjection/LazyResolver`1.cs b/src/Buildvana.Tool/Infrastructure/DependencyInjection/LazyResolver`1.cs new file mode 100644 index 0000000..a4e4b83 --- /dev/null +++ b/src/Buildvana.Tool/Infrastructure/DependencyInjection/LazyResolver`1.cs @@ -0,0 +1,17 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; + +namespace Buildvana.Tool.Infrastructure.DependencyInjection; + +internal sealed class LazyResolver : Lazy + where T : notnull +{ + public LazyResolver(IServiceProvider provider) + : base(provider.GetRequiredService, LazyThreadSafetyMode.ExecutionAndPublication) + { + } +} diff --git a/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..1e7a5b0 --- /dev/null +++ b/src/Buildvana.Tool/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace Buildvana.Tool.Infrastructure.DependencyInjection; + +/// +/// Provides extension methods for to add Buildvana Tool services. +/// +internal static class ServiceCollectionExtensions +{ + extension(IServiceCollection @this) + { + /// + /// Adds support for resolving instances. + /// + /// The service collection for chaining. + public IServiceCollection AddLazySupport() => @this.AddTransient(typeof(Lazy<>), typeof(LazyResolver<>)); + } +} diff --git a/src/Buildvana.Tool/Program.cs b/src/Buildvana.Tool/Program.cs index ce28042..2c10505 100644 --- a/src/Buildvana.Tool/Program.cs +++ b/src/Buildvana.Tool/Program.cs @@ -13,6 +13,7 @@ using Buildvana.Tool.Build; using Buildvana.Tool.CommandLine; using Buildvana.Tool.Configuration; +using Buildvana.Tool.Infrastructure.DependencyInjection; using Buildvana.Tool.Infrastructure.Execution; using Buildvana.Tool.Services; using Buildvana.Tool.Services.Git; @@ -159,6 +160,7 @@ private static ServiceProvider BuildServiceProvider( ParsedCommandLine parsed) { var services = new ServiceCollection() + .AddLazySupport() .AddSingleton(reporter) .AddSingleton(globals) .AddSingleton(new CommandParameters(parsed.OptionTokens, parsed.Forwarded)) From 0932bd0e4ab7d8961c040d4874c3939b21fad113 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Sat, 30 May 2026 15:40:33 +0200 Subject: [PATCH 11/15] Use lazy resolution instead of injecting the service provider in DotNetService --- src/Buildvana.Tool/Services/DotNetService.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Buildvana.Tool/Services/DotNetService.cs b/src/Buildvana.Tool/Services/DotNetService.cs index ddc08d5..fed5ff0 100644 --- a/src/Buildvana.Tool/Services/DotNetService.cs +++ b/src/Buildvana.Tool/Services/DotNetService.cs @@ -15,7 +15,6 @@ using Buildvana.Tool.Subcommands; using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; -using Microsoft.Extensions.DependencyInjection; using IProcessRunner = Buildvana.Core.Process.IProcessRunner; using ProcessResult = Buildvana.Core.Process.ProcessResult; @@ -36,7 +35,7 @@ private static readonly string DotNetMuxer private readonly IReporter _reporter; private readonly IProcessRunner _processRunner; - private readonly IServiceProvider _services; + private readonly Lazy _nugetPushConfigurationLazy; private readonly ServerAdapter _server; private readonly VersionService _version; private readonly GlobalSettings _globals; @@ -47,20 +46,20 @@ private static readonly string DotNetMuxer public DotNetService( IReporter reporter, IProcessRunner processRunner, - IServiceProvider services, + Lazy nugetPushConfigurationLazy, ServerAdapter server, VersionService version, GlobalSettings globals) { Guard.IsNotNull(reporter); Guard.IsNotNull(processRunner); - Guard.IsNotNull(services); + Guard.IsNotNull(nugetPushConfigurationLazy); Guard.IsNotNull(server); Guard.IsNotNull(version); Guard.IsNotNull(globals); _reporter = reporter; _processRunner = processRunner; - _services = services; + _nugetPushConfigurationLazy = nugetPushConfigurationLazy; _server = server; _version = version; _globals = globals; @@ -227,7 +226,7 @@ public async Task NuGetPushAllAsync(string artifactsPath, CancellationToken canc } var isPrivate = await _server.IsPrivateRepositoryAsync().ConfigureAwait(false); - var nugetConfig = _services.GetRequiredService(); + var nugetConfig = _nugetPushConfigurationLazy.Value; var target = isPrivate ? nugetConfig.Private : _version.IsPrerelease ? nugetConfig.Prerelease : nugetConfig.Release; From 34cdc4fa2c68dc6175752ee784cea5e48de12254 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Sat, 30 May 2026 15:52:06 +0200 Subject: [PATCH 12/15] Improve parameter handling in DotNetConfig, as follows: - Build `dotnet` paraneter lists declaratively, preferring ternaries to `if` blocks - Formalize verbosity and output streaming requirements into an `InvocationKind` private enum - Make the intent of the parameter to `ContinuousIntegrationBuildArg()` clearer --- .../Services/DotNetService.InvocationKind.cs | 26 +++ src/Buildvana.Tool/Services/DotNetService.cs | 155 ++++++++++-------- 2 files changed, 112 insertions(+), 69 deletions(-) create mode 100644 src/Buildvana.Tool/Services/DotNetService.InvocationKind.cs diff --git a/src/Buildvana.Tool/Services/DotNetService.InvocationKind.cs b/src/Buildvana.Tool/Services/DotNetService.InvocationKind.cs new file mode 100644 index 0000000..66f02cf --- /dev/null +++ b/src/Buildvana.Tool/Services/DotNetService.InvocationKind.cs @@ -0,0 +1,26 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Tool.Services; + +partial class DotNetService +{ + private enum InvocationKind + { + /// + /// A normal invocation: `dotnet` accepts the `--verbosity` argument and the user is interested in the output. + /// + Normal, + + /// + /// An informational invocation: the user is interested in the output, but `dotnet` does not accept the `--verbosity` argument. + /// + // ReSharper disable once UnusedMember.Local + Informational, + + /// + /// An internal invocation: the user is not interested in the output, and the `--verbosity` argument, if any, is already set appropriately. + /// + Internal, + } +} diff --git a/src/Buildvana.Tool/Services/DotNetService.cs b/src/Buildvana.Tool/Services/DotNetService.cs index fed5ff0..6f745b1 100644 --- a/src/Buildvana.Tool/Services/DotNetService.cs +++ b/src/Buildvana.Tool/Services/DotNetService.cs @@ -12,7 +12,6 @@ using Buildvana.Tool.Services.ServerAdapters; using Buildvana.Tool.Services.Solution; using Buildvana.Tool.Services.Versioning; -using Buildvana.Tool.Subcommands; using Buildvana.Tool.Utilities; using CommunityToolkit.Diagnostics; @@ -24,7 +23,7 @@ namespace Buildvana.Tool.Services; /// /// Provides shortcut methods for .NET SDK operations. /// -internal sealed class DotNetService +internal sealed partial class DotNetService { // The muxer sets DOTNET_HOST_PATH to the full path of the dotnet executable that launched us, // so we re-invoke that exact host instead of relying on `dotnet` being on PATH. @@ -38,7 +37,6 @@ private static readonly string DotNetMuxer private readonly Lazy _nugetPushConfigurationLazy; private readonly ServerAdapter _server; private readonly VersionService _version; - private readonly GlobalSettings _globals; /// /// Initializes a new instance of the class. @@ -48,27 +46,20 @@ public DotNetService( IProcessRunner processRunner, Lazy nugetPushConfigurationLazy, ServerAdapter server, - VersionService version, - GlobalSettings globals) + VersionService version) { Guard.IsNotNull(reporter); Guard.IsNotNull(processRunner); Guard.IsNotNull(nugetPushConfigurationLazy); Guard.IsNotNull(server); Guard.IsNotNull(version); - Guard.IsNotNull(globals); _reporter = reporter; _processRunner = processRunner; _nugetPushConfigurationLazy = nugetPushConfigurationLazy; _server = server; _version = version; - _globals = globals; } - // The verbosity bv forwards to every `dotnet` invocation. bv and the .NET CLI share the same vocabulary - // (quiet/minimal/normal/detailed/diagnostic and their short forms), so the raw value is forwarded as-is. - private string Verbosity => _globals.Verbosity ?? "normal"; - /// /// Asynchronously restores all NuGet packages for the solution. /// @@ -81,10 +72,16 @@ public Task RestoreSolutionAsync(SolutionContext solution, IReadOnlyList Guard.IsNotNull(solution); Guard.IsNotNull(forwardedArgs); _reporter.Info("Restoring NuGet packages for solution..."); - List args = ["restore", solution.SolutionPath, "--disable-parallel", "-nologo", "-v", Verbosity]; - args.AddRange(forwardedArgs); - args.Add(ContinuousIntegrationBuildArg(dotnetTest: false)); - return RunDotNetAsync(args, cancellationToken: cancellationToken); + string[] args = [ + "restore", + solution.SolutionPath, + "--disable-parallel", + "-nologo", + ..forwardedArgs, + ContinuousIntegrationBuildArg(asMSBuildPassthrough: true), + ]; + + return RunDotNetAsync(args, InvocationKind.Normal, cancellationToken: cancellationToken); } /// @@ -102,15 +99,17 @@ public Task BuildSolutionAsync(SolutionContext solution, string configuration, I Guard.IsNotNullOrEmpty(configuration); Guard.IsNotNull(forwardedArgs); _reporter.Info($"Building solution (restore = {restore})..."); - List args = ["build", solution.SolutionPath, "-nologo", "-v", Verbosity, $"-p:Configuration={configuration}"]; - if (!restore) - { - args.Add("--no-restore"); - } + string[] args = [ + "build", + solution.SolutionPath, + "-nologo", + $"-p:Configuration={configuration}", + .. restore ? Array.Empty() : ["--no-restore"], + ..forwardedArgs, + ContinuousIntegrationBuildArg(asMSBuildPassthrough: true), + ]; - args.AddRange(forwardedArgs); - args.Add(ContinuousIntegrationBuildArg(dotnetTest: false)); - return RunDotNetAsync(args, cancellationToken: cancellationToken); + return RunDotNetAsync(args, InvocationKind.Normal, cancellationToken: cancellationToken); } /// @@ -138,8 +137,8 @@ public async Task TestSolutionAsync(SolutionContext solution, string configurati // bv-internal MSBuild evaluation: do not forward the user's arguments here, as they may be // test-application options that `dotnet msbuild` would reject. - List probeArgs = ["msbuild", projectPath, "-nologo", "-getProperty:IsTestingPlatformApplication"]; - var probe = await RunDotNetAsync(probeArgs, streamOutput: false, cancellationToken: cancellationToken).ConfigureAwait(false); + string[] probeArgs = ["msbuild", projectPath, "-nologo", "-getProperty:IsTestingPlatformApplication"]; + var probe = await RunDotNetAsync(probeArgs, InvocationKind.Internal, cancellationToken: cancellationToken).ConfigureAwait(false); if (string.Equals(probe.StandardOutput.Trim(), "true", StringComparison.OrdinalIgnoreCase)) { @@ -160,21 +159,24 @@ public async Task TestSolutionAsync(SolutionContext solution, string configurati // `dotnet test` consumes --verbosity itself; the configuration and ContinuousIntegrationBuild are // passed as MSBuild properties using the `--property:` form, which is what `dotnet test` understands // (the `-p:` form is not supported here). - List args = ["test", solution.SolutionPath, $"--verbosity={Verbosity}", $"--property:Configuration={configuration}"]; - if (!build) - { - args.Add("--no-build"); - } + string[] args = [ + "test", + solution.SolutionPath, + $"--property:Configuration={configuration}", + .. restore ? Array.Empty() : ["--no-restore"], + .. build ? Array.Empty() : ["--no-build"], + "--results-directory", + CommonPaths.TestResults, + "--output", + _reporter.Verbosity >= Verbosity.Detailed ? "Normal" : "Detailed", + "--coverage", + "--coverage-output-format", + "cobertura", + ..forwardedArgs, + ContinuousIntegrationBuildArg(asMSBuildPassthrough: false), + ]; - if (!restore) - { - args.Add("--no-restore"); - } - - args.AddRange(["--coverage", "--coverage-output-format", "cobertura", "--results-directory", CommonPaths.TestResults]); - args.AddRange(forwardedArgs); - args.Add(ContinuousIntegrationBuildArg(dotnetTest: true)); - await RunDotNetAsync(args, cancellationToken: cancellationToken).ConfigureAwait(false); + await RunDotNetAsync(args, InvocationKind.Normal, cancellationToken: cancellationToken).ConfigureAwait(false); } /// @@ -193,20 +195,18 @@ public Task PackSolutionAsync(SolutionContext solution, string configuration, IR Guard.IsNotNullOrEmpty(configuration); Guard.IsNotNull(forwardedArgs); _reporter.Info($"Packing solution (restore = {restore}, build = {build})..."); - List args = ["pack", solution.SolutionPath, "-nologo", "-v", Verbosity, $"-p:Configuration={configuration}"]; - if (!build) - { - args.Add("--no-build"); - } + string[] args = [ + "pack", + solution.SolutionPath, + "-nologo", + $"-p:Configuration={configuration}", + .. restore ? Array.Empty() : ["--no-restore"], + .. build ? Array.Empty() : ["--no-build"], + ..forwardedArgs, + ContinuousIntegrationBuildArg(asMSBuildPassthrough: true), + ]; - if (!restore) - { - args.Add("--no-restore"); - } - - args.AddRange(forwardedArgs); - args.Add(ContinuousIntegrationBuildArg(dotnetTest: false)); - return RunDotNetAsync(args, cancellationToken: cancellationToken); + return RunDotNetAsync(args, InvocationKind.Normal, cancellationToken: cancellationToken); } /// @@ -232,34 +232,51 @@ public async Task NuGetPushAllAsync(string artifactsPath, CancellationToken canc : nugetConfig.Release; foreach (var path in packages) { - _reporter.Info($"Pushing {path} to {target.Source}..."); - await _processRunner - .RunAsync( - DotNetMuxer, - ["nuget", "push", path, "--source", target.Source, "--api-key", target.ApiKey, "--skip-duplicate", "--force-english-output"], - cancellationToken: cancellationToken) - .ConfigureAwait(false); + _reporter.Detail($"Pushing {path} to {target.Source}..."); + string[] args = [ + "nuget", + "push", + path, + "--source", + target.Source, + "--api-key", + target.ApiKey, + "--skip-duplicate", + "--force-english-output", + ]; + await _processRunner.RunAsync(DotNetMuxer, args, cancellationToken: cancellationToken).ConfigureAwait(false); } + + _reporter.Info($"Pushed {packages.Length} packages to {target.Source}."); } - // bv's authoritative ContinuousIntegrationBuild value, emitted in the trailing group so it wins under - // MSBuild's last-wins resolution. `dotnet test` requires the `--property:` form; the other verbs accept `-p:`. - private string ContinuousIntegrationBuildArg(bool dotnetTest) + /// + /// Return a parameter string that reflects whether we're running in CI. + /// + /// to use the MSBuild passthrough form (-p:), + /// to use the standard form (--property:). + /// A string representing the ContinuousIntegrationBuild property setting parameter for the dotnet command. + private string ContinuousIntegrationBuildArg(bool asMSBuildPassthrough) { - var prefix = dotnetTest ? "--property:" : "-p:"; + var prefix = asMSBuildPassthrough ? "-p:" : "--property:"; return $"{prefix}ContinuousIntegrationBuild={(_server.IsCloudBuild ? "true" : "false")}"; } - // streamOutput is false for bv-internal evaluations (e.g. the MTP probe) whose output is captured and - // inspected rather than shown to the user; user-facing verbs stream the child's output live. + /// + /// Runs a dotnet command with the specified arguments, forwarding the output to the reporter according to the current verbosity. + /// + /// The arguments to pass to dotnet, excluding the verbosity argument, which is automatically appended according to the current verbosity. + /// The kind of invocation, which determines how `dotnet` verbosity and output streaming are handled. + /// A token that, when signalled, terminates the spawned dotnet child process. + /// A representing the ongoing operation, with a result describing child process outcome. private Task RunDotNetAsync( IEnumerable args, - bool streamOutput = true, + InvocationKind invocationKind, CancellationToken cancellationToken = default) => _processRunner.RunAsync( DotNetMuxer, - args, - onStdout: streamOutput ? (x) => _reporter.ChildOutput(x, null) : null, - onStderr: streamOutput ? (x) => _reporter.ChildError(x, null) : null, + invocationKind == InvocationKind.Normal ? args.Append($"--verbosity={_reporter.Verbosity}") : args, + onStdout: invocationKind != InvocationKind.Internal ? (x) => _reporter.ChildOutput(x, null) : null, + onStderr: invocationKind != InvocationKind.Internal ? (x) => _reporter.ChildError(x, null) : null, cancellationToken: cancellationToken); } From d9b0246f8031c6a77fe901ae4e13fa108bfba840 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Sat, 30 May 2026 16:08:36 +0200 Subject: [PATCH 13/15] Remove project-specific `dotnet` parameters - Coverage-related parameters are not in the core MTP parameter set; therefore, they have to be specified via configuration file. Otherwise, `bv test` will fail if at least a test project does not depend on `Microsoft.Testing.Extensions.CodeCoverage`. - Passing `--force-english-output` to `dotnet nuget` should be the user's choice, not an imposition. --- src/Buildvana.Tool/Services/DotNetService.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Buildvana.Tool/Services/DotNetService.cs b/src/Buildvana.Tool/Services/DotNetService.cs index 6f745b1..281d74b 100644 --- a/src/Buildvana.Tool/Services/DotNetService.cs +++ b/src/Buildvana.Tool/Services/DotNetService.cs @@ -169,9 +169,6 @@ public async Task TestSolutionAsync(SolutionContext solution, string configurati CommonPaths.TestResults, "--output", _reporter.Verbosity >= Verbosity.Detailed ? "Normal" : "Detailed", - "--coverage", - "--coverage-output-format", - "cobertura", ..forwardedArgs, ContinuousIntegrationBuildArg(asMSBuildPassthrough: false), ]; @@ -242,7 +239,6 @@ public async Task NuGetPushAllAsync(string artifactsPath, CancellationToken canc "--api-key", target.ApiKey, "--skip-duplicate", - "--force-english-output", ]; await _processRunner.RunAsync(DotNetMuxer, args, cancellationToken: cancellationToken).ConfigureAwait(false); } From 46676fa614afced26fb9baedd0e8f6438f564972 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Sat, 30 May 2026 16:48:18 +0200 Subject: [PATCH 14/15] Fix: inverted condition in verbosity checks --- .../ConsoleOutput/IReporter.cs | 12 ++++++------ .../ConsoleOutput/NullReporter.cs | 4 ++-- .../ConsoleOutput/ReporterExtensions.cs | 11 +++++++++++ src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs | 12 ++++++------ src/Buildvana.Tool/Services/DotNetService.cs | 2 +- 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs index 3a7f56d..c63937c 100644 --- a/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/IReporter.cs @@ -36,16 +36,16 @@ public interface IReporter /// Used to stream a spawned process's standard output through to this process's standard output. /// /// The line of child-process standard output to write. - /// The verbosity level at which the line should be written. - /// If `null`, the line is written regardless of the current verbosity. - void ChildOutput(string line, Verbosity? verbosity); + /// The line is written only when the reporter's is at + /// least this value. If , the line is always written. + void ChildOutput(string line, Verbosity? minimumVerbosity); /// /// Writes a line from a child process's standard error verbatim: no level label, no color, no category. /// Used to stream a spawned process's standard error through to this process's standard error. /// /// The line of child-process standard error to write. - /// The verbosity level at which the line should be written. - /// If `null`, the line is written regardless of the current verbosity. - void ChildError(string line, Verbosity? verbosity); + /// The line is written only when the reporter's is at + /// least this value. If , the line is always written. + void ChildError(string line, Verbosity? minimumVerbosity); } diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs index 6b79b5b..6071f98 100644 --- a/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/NullReporter.cs @@ -30,12 +30,12 @@ public void Report(MessageLevel level, string message) public IActivityScope BeginActivity(string title) => NullActivityScope.Instance; /// - public void ChildOutput(string line, Verbosity? verbosity) + public void ChildOutput(string line, Verbosity? minimumVerbosity) { } /// - public void ChildError(string line, Verbosity? verbosity) + public void ChildError(string line, Verbosity? minimumVerbosity) { } } diff --git a/src/Buildvana.Core.Abstractions/ConsoleOutput/ReporterExtensions.cs b/src/Buildvana.Core.Abstractions/ConsoleOutput/ReporterExtensions.cs index f20892f..6ca74fc 100644 --- a/src/Buildvana.Core.Abstractions/ConsoleOutput/ReporterExtensions.cs +++ b/src/Buildvana.Core.Abstractions/ConsoleOutput/ReporterExtensions.cs @@ -96,5 +96,16 @@ public void Report(MessageLevel level, CompositeFormat format, params ReadOnlySp /// The level to test. /// if the level is enabled; otherwise, . public bool IsEnabled(MessageLevel level) => (int)level <= (int)@this.Verbosity; + + /// + /// Determines whether the reporter's is at least the given + /// . + /// + /// The minimum verbosity to test against. + /// + /// if the reporter's verbosity is at least ; + /// otherwise, . + /// + public bool IsVerbosityAtLeast(Verbosity minimumVerbosity) => (int)minimumVerbosity <= (int)@this.Verbosity; } } diff --git a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs index 296bebe..7b2a6be 100644 --- a/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs +++ b/src/Buildvana.Core.ConsoleOutput/ConsoleReporter.cs @@ -79,13 +79,13 @@ public IActivityScope BeginActivity(string title) } /// - public void ChildOutput(string line, Verbosity? verbosity) + public void ChildOutput(string line, Verbosity? minimumVerbosity) { Guard.IsNotNull(line); - // If a verbosity is specified, respect it; otherwise, write regardless of current verbosity. + // If a minimum verbosity is specified, respect it; otherwise, write regardless of current verbosity. // This is useful for child processes whose verbosity we control, e.g., `dotnet`. - if (verbosity is not null && (int)verbosity <= (int)Verbosity) + if (minimumVerbosity is { } v && !this.IsVerbosityAtLeast(v)) { return; } @@ -97,13 +97,13 @@ public void ChildOutput(string line, Verbosity? verbosity) } /// - public void ChildError(string line, Verbosity? verbosity) + public void ChildError(string line, Verbosity? minimumVerbosity) { Guard.IsNotNull(line); - // If a verbosity is specified, respect it; otherwise, write regardless of current verbosity. + // If a minimum verbosity is specified, respect it; otherwise, write regardless of current verbosity. // This is useful for child processes whose verbosity we control, e.g., `dotnet`. - if (verbosity is not null && (int)verbosity <= (int)Verbosity) + if (minimumVerbosity is { } v && !this.IsVerbosityAtLeast(v)) { return; } diff --git a/src/Buildvana.Tool/Services/DotNetService.cs b/src/Buildvana.Tool/Services/DotNetService.cs index 281d74b..ae6a71c 100644 --- a/src/Buildvana.Tool/Services/DotNetService.cs +++ b/src/Buildvana.Tool/Services/DotNetService.cs @@ -168,7 +168,7 @@ public async Task TestSolutionAsync(SolutionContext solution, string configurati "--results-directory", CommonPaths.TestResults, "--output", - _reporter.Verbosity >= Verbosity.Detailed ? "Normal" : "Detailed", + _reporter.IsVerbosityAtLeast(Verbosity.Detailed) ? "Detailed" : "Normal", ..forwardedArgs, ContinuousIntegrationBuildArg(asMSBuildPassthrough: false), ]; From b444f18138fc76ad36e17fe5b25e86980dec8b48 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Sat, 30 May 2026 17:11:05 +0200 Subject: [PATCH 15/15] Improve code style in DotNetService.RunDotNetAsync() As a side effect, InvocationKind members are now referenced at least once, removing the cause for the ReSharoper suppression on Informational and preventing reviewers from flagging it as dead code. --- .../Services/DotNetService.InvocationKind.cs | 1 - src/Buildvana.Tool/Services/DotNetService.cs | 18 ++++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Buildvana.Tool/Services/DotNetService.InvocationKind.cs b/src/Buildvana.Tool/Services/DotNetService.InvocationKind.cs index 66f02cf..b72f106 100644 --- a/src/Buildvana.Tool/Services/DotNetService.InvocationKind.cs +++ b/src/Buildvana.Tool/Services/DotNetService.InvocationKind.cs @@ -15,7 +15,6 @@ private enum InvocationKind /// /// An informational invocation: the user is interested in the output, but `dotnet` does not accept the `--verbosity` argument. /// - // ReSharper disable once UnusedMember.Local Informational, /// diff --git a/src/Buildvana.Tool/Services/DotNetService.cs b/src/Buildvana.Tool/Services/DotNetService.cs index ae6a71c..86e6d76 100644 --- a/src/Buildvana.Tool/Services/DotNetService.cs +++ b/src/Buildvana.Tool/Services/DotNetService.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -269,10 +270,19 @@ private Task RunDotNetAsync( IEnumerable args, InvocationKind invocationKind, CancellationToken cancellationToken = default) - => _processRunner.RunAsync( + { + var (appendVerbosity, streamOutput) = invocationKind switch { + InvocationKind.Normal => (AppendVerbosity: true, StreamOutput: true), + InvocationKind.Informational => (AppendVerbosity: false, StreamOutput: true), + InvocationKind.Internal => (AppendVerbosity: false, StreamOutput: false), + _ => throw new UnreachableException(), + }; + + return _processRunner.RunAsync( DotNetMuxer, - invocationKind == InvocationKind.Normal ? args.Append($"--verbosity={_reporter.Verbosity}") : args, - onStdout: invocationKind != InvocationKind.Internal ? (x) => _reporter.ChildOutput(x, null) : null, - onStderr: invocationKind != InvocationKind.Internal ? (x) => _reporter.ChildError(x, null) : null, + appendVerbosity ? args.Append($"--verbosity={_reporter.Verbosity}") : args, + onStdout: streamOutput ? (x) => _reporter.ChildOutput(x, null) : null, + onStderr: streamOutput ? (x) => _reporter.ChildError(x, null) : null, cancellationToken: cancellationToken); + } }