diff --git a/src/Build/Logging/CICDLogger/AzureDevOps/AzureDevOpsForwardingLogger.cs b/src/Build/Logging/CICDLogger/AzureDevOps/AzureDevOpsForwardingLogger.cs new file mode 100644 index 00000000000..dce9ce365dd --- /dev/null +++ b/src/Build/Logging/CICDLogger/AzureDevOps/AzureDevOpsForwardingLogger.cs @@ -0,0 +1,76 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Framework; + +#nullable enable + +namespace Microsoft.Build.Logging.CICDLogger.AzureDevOps; + +/// +/// Forwarding logger for Azure DevOps that filters and forwards events from build nodes to the central logger. +/// +public sealed class AzureDevOpsForwardingLogger : IForwardingLogger +{ + /// + public IEventRedirector? BuildEventRedirector { get; set; } + + /// + public int NodeId { get; set; } + + /// + public LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Normal; + + /// + public string? Parameters { get; set; } + + /// + public void Initialize(IEventSource eventSource, int nodeCount) + { + NodeId = nodeCount; + Initialize(eventSource); + } + + /// + public void Initialize(IEventSource eventSource) + { + // Forward all errors and warnings unconditionally + eventSource.ErrorRaised += ForwardEvent; + eventSource.WarningRaised += ForwardEvent; + + // Forward build lifecycle events + eventSource.BuildStarted += ForwardEvent; + eventSource.BuildFinished += ForwardEvent; + eventSource.ProjectStarted += ForwardEvent; + eventSource.ProjectFinished += ForwardEvent; + + // Forward messages based on importance and verbosity + eventSource.MessageRaised += MessageRaised; + } + + /// + public void Shutdown() + { + } + + private void ForwardEvent(object sender, BuildEventArgs e) + { + BuildEventRedirector?.ForwardEvent(e); + } + + private void MessageRaised(object sender, BuildMessageEventArgs e) + { + // Forward messages based on verbosity + if (Verbosity == LoggerVerbosity.Quiet) + { + return; + } + + if (e.Importance == MessageImportance.High || + (e.Importance == MessageImportance.Normal && Verbosity >= LoggerVerbosity.Normal) || + (e.Importance == MessageImportance.Low && Verbosity >= LoggerVerbosity.Detailed)) + { + BuildEventRedirector?.ForwardEvent(e); + } + } +} diff --git a/src/Build/Logging/CICDLogger/AzureDevOps/AzureDevOpsLogger.cs b/src/Build/Logging/CICDLogger/AzureDevOps/AzureDevOpsLogger.cs new file mode 100644 index 00000000000..a2eece43cb9 --- /dev/null +++ b/src/Build/Logging/CICDLogger/AzureDevOps/AzureDevOpsLogger.cs @@ -0,0 +1,354 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.Build.Framework; + +#nullable enable + +namespace Microsoft.Build.Logging.CICDLogger.AzureDevOps; + +/// +/// Data captured from project evaluation. +/// +public sealed class AzureDevOpsEvalData +{ + public string? TargetFramework { get; set; } + public string? RuntimeIdentifier { get; set; } +} + +/// +/// Wrapper for build events with timestamps to maintain ordering. +/// +public sealed class TimestampedBuildEvent +{ + public DateTime Timestamp { get; } + public BuildEventArgs Event { get; } + + public TimestampedBuildEvent(BuildEventArgs evt) + { + Event = evt; + Timestamp = evt.Timestamp; + } +} + +/// +/// Data stored for each project during the build. +/// +public sealed class AzureDevOpsProjectData +{ + public string? ProjectFile { get; set; } + public string? TargetFramework { get; set; } + public string? RuntimeIdentifier { get; set; } + public List Events { get; } = new(); + + public int ErrorCount => Events.Count(e => e.Event is BuildErrorEventArgs); + public int WarningCount => Events.Count(e => e.Event is BuildWarningEventArgs); +} + +/// +/// Data stored for the entire build session. +/// +public sealed class AzureDevOpsBuildData +{ + public int TotalErrors { get; set; } + public int TotalWarnings { get; set; } +} + +/// +/// Logger for Azure DevOps that formats build diagnostics using Azure Pipelines logging commands. +/// See: https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands +/// +public sealed class AzureDevOpsLogger : ProjectTrackingLoggerBase +{ + private Action _write = Console.Out.Write; + + /// + public override LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Normal; + + /// + public override string? Parameters { get; set; } + + /// + /// Detects if Azure DevOps environment is active. + /// + /// true if running in Azure DevOps; otherwise, false. + public static bool IsEnabled() + { + return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TF_BUILD")); + } + + /// + public override void Shutdown() + { + } + + #region Abstract method implementations + + protected override AzureDevOpsEvalData CreateEvalData(ProjectEvaluationFinishedEventArgs e) + { + var evalData = new AzureDevOpsEvalData(); + + // Extract target framework and runtime identifier from evaluation properties + if (e.Properties != null) + { + foreach (var item in e.Properties) + { + if (item is System.Collections.DictionaryEntry kvp) + { + var key = kvp.Key as string; + if (key == "TargetFramework") + { + evalData.TargetFramework = kvp.Value?.ToString(); + } + else if (key == "RuntimeIdentifier") + { + evalData.RuntimeIdentifier = kvp.Value?.ToString(); + } + } + } + } + + return evalData; + } + + protected override AzureDevOpsProjectData? CreateProjectData(AzureDevOpsEvalData evalData, ProjectStartedEventArgs e) + { + return new AzureDevOpsProjectData + { + ProjectFile = e.ProjectFile, + TargetFramework = evalData?.TargetFramework, + RuntimeIdentifier = evalData?.RuntimeIdentifier + }; + } + + protected override AzureDevOpsBuildData CreateBuildData(BuildStartedEventArgs e) + { + return new AzureDevOpsBuildData(); + } + + #endregion + + #region Event handlers + + protected override void OnErrorRaised(BuildErrorEventArgs e, AzureDevOpsProjectData? projectData, AzureDevOpsBuildData buildData) + { + buildData.TotalErrors++; + + if (projectData != null) + { + // Buffer error for output at project finished + projectData.Events.Add(new TimestampedBuildEvent(e)); + } + else + { + // No project context, write immediately + WriteDiagnostic("error", e.File, e.LineNumber, e.ColumnNumber, e.Code, e.Message); + } + } + + protected override void OnWarningRaised(BuildWarningEventArgs e, AzureDevOpsProjectData? projectData, AzureDevOpsBuildData buildData) + { + buildData.TotalWarnings++; + + if (projectData != null) + { + // Buffer warning for output at project finished + projectData.Events.Add(new TimestampedBuildEvent(e)); + } + else + { + // No project context, write immediately + WriteDiagnostic("warning", e.File, e.LineNumber, e.ColumnNumber, e.Code, e.Message); + } + } + + protected override void OnMessageRaised(BuildMessageEventArgs e, AzureDevOpsProjectData? projectData, AzureDevOpsBuildData buildData) + { + if (Verbosity == LoggerVerbosity.Quiet) + { + return; + } + + // First question: should I care about this message? + bool shouldLog = e.Importance == MessageImportance.High || + (e.Importance == MessageImportance.Normal && Verbosity >= LoggerVerbosity.Normal) || + (e.Importance == MessageImportance.Low && Verbosity >= LoggerVerbosity.Detailed); + + if (!shouldLog) + { + return; + } + + // Second question: do I log it now or at the end of the project build? + if (projectData != null) + { + // Buffer for output at project finished + projectData.Events.Add(new TimestampedBuildEvent(e)); + } + else + { + // No project context, write immediately + _write(e.Message ?? string.Empty); + _write(Environment.NewLine); + } + } + + protected override void OnProjectFinished(ProjectFinishedEventArgs e, AzureDevOpsProjectData projectData, AzureDevOpsBuildData buildData) + { + if (Verbosity >= LoggerVerbosity.Normal) + { + // Build project header with context information + var header = new StringBuilder(); + header.Append("Building "); + header.Append(projectData.ProjectFile ?? e.ProjectFile ?? "project"); + + if (!string.IsNullOrEmpty(projectData.TargetFramework)) + { + header.Append(" ("); + header.Append(projectData.TargetFramework); + + if (!string.IsNullOrEmpty(projectData.RuntimeIdentifier)) + { + header.Append(" | "); + header.Append(projectData.RuntimeIdentifier); + } + + header.Append(')'); + } + + // Add success/failure status + if (!e.Succeeded) + { + header.Append(" - Failed"); + } + else if (projectData.ErrorCount > 0 || projectData.WarningCount > 0) + { + header.Append($" - {projectData.ErrorCount} error(s), {projectData.WarningCount} warning(s)"); + } + + // Output group header (collapsible) + _write($"##[group]{header}"); + _write(Environment.NewLine); + + // Output all events in timestamp order + foreach (var timestampedEvent in projectData.Events.OrderBy(e => e.Timestamp)) + { + switch (timestampedEvent.Event) + { + case BuildErrorEventArgs error: + WriteDiagnostic("error", error.File, error.LineNumber, error.ColumnNumber, error.Code, error.Message); + break; + case BuildWarningEventArgs warning: + WriteDiagnostic("warning", warning.File, warning.LineNumber, warning.ColumnNumber, warning.Code, warning.Message); + break; + case BuildMessageEventArgs message: + _write(message.Message ?? string.Empty); + _write(Environment.NewLine); + break; + } + } + + // End the group + _write("##[endgroup]"); + _write(Environment.NewLine); + } + } + + protected override void OnBuildFinished(BuildFinishedEventArgs e, AzureDevOpsProjectData[] projectData, AzureDevOpsBuildData buildData) + { + if (Verbosity >= LoggerVerbosity.Minimal) + { + if (!e.Succeeded) + { + _write($"##vso[task.complete result=Failed]Build failed. {buildData.TotalErrors} error(s), {buildData.TotalWarnings} warning(s)"); + } + else + { + _write($"Build succeeded. {buildData.TotalWarnings} warning(s)"); + } + _write(Environment.NewLine); + } + } + + #endregion + + #region Helper methods + + /// + /// Writes a diagnostic (error or warning) using Azure DevOps logging commands. + /// + private void WriteDiagnostic(string type, string? file, int lineNumber, int columnNumber, string? code, string? message) + { + // Format: ##vso[task.logissue type={type};sourcepath=file;linenumber=line;columnnumber=col;code=code;]message + var output = new StringBuilder(); + output.Append("##vso[task.logissue type="); + output.Append(type); + + if (!string.IsNullOrEmpty(file)) + { + output.Append(";sourcepath="); + output.Append(EscapeProperty(file!)); + + if (lineNumber > 0) + { + output.Append(";linenumber="); + output.Append(lineNumber); + + if (columnNumber > 0) + { + output.Append(";columnnumber="); + output.Append(columnNumber); + } + } + } + + if (!string.IsNullOrEmpty(code)) + { + output.Append(";code="); + output.Append(EscapeProperty(code!)); + } + + output.Append(']'); + output.Append(EscapeData(message ?? string.Empty)); + output.AppendLine(); + + _write(output.ToString()); + } + + /// + /// Escapes special characters in property values for Azure DevOps logging commands. + /// + private static string EscapeProperty(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + return value.Replace(";", "%3B") + .Replace("\r", "%0D") + .Replace("\n", "%0A") + .Replace("]", "%5D"); + } + + /// + /// Escapes special characters in message data for Azure DevOps logging commands. + /// + private static string EscapeData(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + return value.Replace("\r", "%0D") + .Replace("\n", "%0A") + .Replace("]", "%5D"); + } + + #endregion +} diff --git a/src/Build/Logging/CICDLogger/GitHubActions/GitHubActionsForwardingLogger.cs b/src/Build/Logging/CICDLogger/GitHubActions/GitHubActionsForwardingLogger.cs new file mode 100644 index 00000000000..4c625476662 --- /dev/null +++ b/src/Build/Logging/CICDLogger/GitHubActions/GitHubActionsForwardingLogger.cs @@ -0,0 +1,76 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Framework; + +#nullable enable + +namespace Microsoft.Build.Logging.CICDLogger.GitHubActions; + +/// +/// Forwarding logger for GitHub Actions that filters and forwards events from build nodes to the central logger. +/// +public sealed class GitHubActionsForwardingLogger : IForwardingLogger +{ + /// + public IEventRedirector? BuildEventRedirector { get; set; } + + /// + public int NodeId { get; set; } + + /// + public LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Normal; + + /// + public string? Parameters { get; set; } + + /// + public void Initialize(IEventSource eventSource, int nodeCount) + { + NodeId = nodeCount; + Initialize(eventSource); + } + + /// + public void Initialize(IEventSource eventSource) + { + // Forward all errors and warnings unconditionally + eventSource.ErrorRaised += ForwardEvent; + eventSource.WarningRaised += ForwardEvent; + + // Forward build lifecycle events + eventSource.BuildStarted += ForwardEvent; + eventSource.BuildFinished += ForwardEvent; + eventSource.ProjectStarted += ForwardEvent; + eventSource.ProjectFinished += ForwardEvent; + + // Forward messages based on importance and verbosity + eventSource.MessageRaised += MessageRaised; + } + + /// + public void Shutdown() + { + } + + private void ForwardEvent(object sender, BuildEventArgs e) + { + BuildEventRedirector?.ForwardEvent(e); + } + + private void MessageRaised(object sender, BuildMessageEventArgs e) + { + // Forward messages based on verbosity + if (Verbosity == LoggerVerbosity.Quiet) + { + return; + } + + if (e.Importance == MessageImportance.High || + (e.Importance == MessageImportance.Normal && Verbosity >= LoggerVerbosity.Normal) || + (e.Importance == MessageImportance.Low && Verbosity >= LoggerVerbosity.Detailed)) + { + BuildEventRedirector?.ForwardEvent(e); + } + } +} diff --git a/src/Build/Logging/CICDLogger/GitHubActions/GitHubActionsLogger.cs b/src/Build/Logging/CICDLogger/GitHubActions/GitHubActionsLogger.cs new file mode 100644 index 00000000000..05d35064a21 --- /dev/null +++ b/src/Build/Logging/CICDLogger/GitHubActions/GitHubActionsLogger.cs @@ -0,0 +1,524 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.Build.Framework; + +#nullable enable + +namespace Microsoft.Build.Logging.CICDLogger.GitHubActions; + +/// +/// Data captured from project evaluation. +/// +public sealed class GitHubActionsEvalData +{ + public string? TargetFramework { get; set; } + public string? RuntimeIdentifier { get; set; } +} + +/// +/// Wrapper for build events with timestamps to maintain ordering. +/// +public sealed class TimestampedBuildEvent +{ + public DateTime Timestamp { get; } + public BuildEventArgs Event { get; } + + public TimestampedBuildEvent(BuildEventArgs evt) + { + Event = evt; + Timestamp = evt.Timestamp; + } +} + +/// +/// Data stored for each project during the build. +/// +public sealed class GitHubActionsProjectData +{ + public string? ProjectFile { get; set; } + public string? TargetFramework { get; set; } + public string? RuntimeIdentifier { get; set; } + public List Events { get; } = new(); + + public int ErrorCount => Events.Count(e => e.Event is BuildErrorEventArgs); + public int WarningCount => Events.Count(e => e.Event is BuildWarningEventArgs); +} + +/// +/// Data stored for the entire build session. +/// +public sealed class GitHubActionsBuildData +{ + public int TotalErrors { get; set; } + public int TotalWarnings { get; set; } +} + +/// +/// Logger for GitHub Actions that formats build diagnostics using GitHub Actions workflow commands. +/// See: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions +/// +public sealed class GitHubActionsLogger : ProjectTrackingLoggerBase +{ + private Action _write = Console.Out.Write; + + /// + public override LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Normal; + + /// + public override string? Parameters { get; set; } + + /// + public override void Initialize(IEventSource eventSource, int nodeCount) + { + // Check for ACTIONS_STEP_DEBUG to force diagnostic verbosity + if (Traits.IsEnvVarOneOrTrue("ACTIONS_STEP_DEBUG")) + { + Verbosity = LoggerVerbosity.Diagnostic; + } + + base.Initialize(eventSource, nodeCount); + } + + /// + /// Detects if GitHub Actions environment is active. + /// + /// true if running in GitHub Actions; otherwise, false. + public static bool IsEnabled() + { + return Traits.IsEnvVarOneOrTrue("GITHUB_ACTIONS"); + } + + /// + public override void Shutdown() + { + } + + #region Abstract method implementations + + protected override GitHubActionsEvalData CreateEvalData(ProjectEvaluationFinishedEventArgs e) + { + var evalData = new GitHubActionsEvalData(); + + // Extract target framework and runtime identifier from evaluation properties + if (e.Properties != null) + { + foreach (var item in e.Properties) + { + if (item is System.Collections.DictionaryEntry kvp) + { + var key = kvp.Key as string; + if (key == "TargetFramework") + { + evalData.TargetFramework = kvp.Value?.ToString(); + } + else if (key == "RuntimeIdentifier") + { + evalData.RuntimeIdentifier = kvp.Value?.ToString(); + } + } + } + } + + return evalData; + } + + protected override GitHubActionsProjectData? CreateProjectData(GitHubActionsEvalData evalData, ProjectStartedEventArgs e) + { + return new GitHubActionsProjectData + { + ProjectFile = e.ProjectFile, + TargetFramework = evalData?.TargetFramework, + RuntimeIdentifier = evalData?.RuntimeIdentifier + }; + } + + protected override GitHubActionsBuildData CreateBuildData(BuildStartedEventArgs e) + { + return new GitHubActionsBuildData(); + } + + #endregion + + #region Event handlers + + protected override void OnErrorRaised(BuildErrorEventArgs e, GitHubActionsProjectData? projectData, GitHubActionsBuildData buildData) + { + buildData.TotalErrors++; + + if (projectData != null) + { + // Buffer error for output at project finished + projectData.Events.Add(new TimestampedBuildEvent(e)); + } + else + { + // No project context, write immediately + WriteDiagnostic("error", e.File, e.LineNumber, e.ColumnNumber, e.EndColumnNumber, e.Code, e.Message); + } + } + + protected override void OnWarningRaised(BuildWarningEventArgs e, GitHubActionsProjectData? projectData, GitHubActionsBuildData buildData) + { + buildData.TotalWarnings++; + + if (projectData != null) + { + // Buffer warning for output at project finished + projectData.Events.Add(new TimestampedBuildEvent(e)); + } + else + { + // No project context, write immediately + WriteDiagnostic("warning", e.File, e.LineNumber, e.ColumnNumber, e.EndColumnNumber, e.Code, e.Message); + } + } + + protected override void OnMessageRaised(BuildMessageEventArgs e, GitHubActionsProjectData? projectData, GitHubActionsBuildData buildData) + { + if (Verbosity == LoggerVerbosity.Quiet) + { + return; + } + + // First question: should I care about this message? + bool shouldLog = e.Importance == MessageImportance.High || + (e.Importance == MessageImportance.Normal && Verbosity >= LoggerVerbosity.Normal) || + (e.Importance == MessageImportance.Low && Verbosity >= LoggerVerbosity.Detailed); + + if (!shouldLog) + { + return; + } + + // Second question: do I log it now or at the end of the project build? + if (projectData != null) + { + // Buffer for output at project finished + projectData.Events.Add(new TimestampedBuildEvent(e)); + } + else + { + // No project context, write immediately + _write(e.Message ?? string.Empty); + _write(Environment.NewLine); + } + } + + protected override void OnProjectFinished(ProjectFinishedEventArgs e, GitHubActionsProjectData projectData, GitHubActionsBuildData buildData) + { + if (Verbosity >= LoggerVerbosity.Normal) + { + // Build project header with context information + var header = new StringBuilder(); + header.Append("Building "); + header.Append(projectData.ProjectFile ?? e.ProjectFile ?? "project"); + + if (!string.IsNullOrEmpty(projectData.TargetFramework)) + { + header.Append(" ("); + header.Append(projectData.TargetFramework); + + if (!string.IsNullOrEmpty(projectData.RuntimeIdentifier)) + { + header.Append(" | "); + header.Append(projectData.RuntimeIdentifier); + } + + header.Append(')'); + } + + // Add success/failure status + if (!e.Succeeded) + { + header.Append(" - Failed"); + } + else if (projectData.ErrorCount > 0 || projectData.WarningCount > 0) + { + header.Append($" - {projectData.ErrorCount} error(s), {projectData.WarningCount} warning(s)"); + } + + // Use groups to collapse project output in GitHub Actions + _write($"::group::{header}"); + _write(Environment.NewLine); + + // Output all events in timestamp order + foreach (var timestampedEvent in projectData.Events.OrderBy(e => e.Timestamp)) + { + switch (timestampedEvent.Event) + { + case BuildErrorEventArgs error: + WriteDiagnostic("error", error.File, error.LineNumber, error.ColumnNumber, error.EndColumnNumber, error.Code, error.Message); + break; + case BuildWarningEventArgs warning: + WriteDiagnostic("warning", warning.File, warning.LineNumber, warning.ColumnNumber, warning.EndColumnNumber, warning.Code, warning.Message); + break; + case BuildMessageEventArgs message: + _write(message.Message ?? string.Empty); + _write(Environment.NewLine); + break; + } + } + + _write("::endgroup::"); + _write(Environment.NewLine); + } + } + + protected override void OnBuildFinished(BuildFinishedEventArgs e, GitHubActionsProjectData[] projectData, GitHubActionsBuildData buildData) + { + if (Verbosity >= LoggerVerbosity.Minimal) + { + if (!e.Succeeded) + { + _write($"Build failed. {buildData.TotalErrors} error(s), {buildData.TotalWarnings} warning(s)"); + } + else + { + _write($"Build succeeded. {buildData.TotalWarnings} warning(s)"); + } + _write(Environment.NewLine); + } + + // Write build summary to GITHUB_STEP_SUMMARY file if available + WriteStepSummary(e, projectData, buildData); + } + + #endregion + + #region Helper methods + + /// + /// Writes the build summary to the GitHub Step Summary file. + /// + private void WriteStepSummary(BuildFinishedEventArgs e, GitHubActionsProjectData[] projectData, GitHubActionsBuildData buildData) + { + var summaryFile = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY"); + if (string.IsNullOrEmpty(summaryFile)) + { + return; + } + + try + { + using var writer = new System.IO.StreamWriter(summaryFile, append: true); + + // Write header + writer.WriteLine("## Build Summary"); + writer.WriteLine(); + + // Write overall status + if (e.Succeeded) + { + writer.WriteLine("✅ **Build Succeeded**"); + } + else + { + writer.WriteLine("❌ **Build Failed**"); + } + writer.WriteLine(); + + // Write summary table + writer.WriteLine($"- **Total Errors:** {buildData.TotalErrors}"); + writer.WriteLine($"- **Total Warnings:** {buildData.TotalWarnings}"); + writer.WriteLine(); + + // Write per-project details + if (projectData.Length > 0) + { + writer.WriteLine("### Project Details"); + writer.WriteLine(); + + foreach (var project in projectData) + { + // Project header + writer.Write("#### "); + writer.Write(project.ProjectFile ?? "project"); + + if (!string.IsNullOrEmpty(project.TargetFramework)) + { + writer.Write(" ("); + writer.Write(project.TargetFramework); + if (!string.IsNullOrEmpty(project.RuntimeIdentifier)) + { + writer.Write(" | "); + writer.Write(project.RuntimeIdentifier); + } + writer.Write(")"); + } + writer.WriteLine(); + writer.WriteLine(); + + if (project.ErrorCount > 0 || project.WarningCount > 0) + { + writer.WriteLine($"- Errors: {project.ErrorCount}"); + writer.WriteLine($"- Warnings: {project.WarningCount}"); + writer.WriteLine(); + + // Write diagnostics ordered by timestamp + if (project.Events.Count > 0) + { + writer.WriteLine("
"); + writer.WriteLine("View Diagnostics"); + writer.WriteLine(); + + foreach (var timestampedEvent in project.Events.OrderBy(e => e.Timestamp)) + { + switch (timestampedEvent.Event) + { + case BuildErrorEventArgs error: + writer.Write("❌ **Error** "); + if (!string.IsNullOrEmpty(error.Code)) + { + writer.Write($"`{error.Code}` "); + } + if (!string.IsNullOrEmpty(error.File)) + { + writer.Write($"in `{error.File}`"); + if (error.LineNumber > 0) + { + writer.Write($" (line {error.LineNumber}"); + if (error.ColumnNumber > 0) + { + writer.Write($", col {error.ColumnNumber}"); + } + writer.Write(")"); + } + } + writer.WriteLine(); + writer.WriteLine($" {error.Message}"); + writer.WriteLine(); + break; + + case BuildWarningEventArgs warning: + writer.Write("⚠️ **Warning** "); + if (!string.IsNullOrEmpty(warning.Code)) + { + writer.Write($"`{warning.Code}` "); + } + if (!string.IsNullOrEmpty(warning.File)) + { + writer.Write($"in `{warning.File}`"); + if (warning.LineNumber > 0) + { + writer.Write($" (line {warning.LineNumber}"); + if (warning.ColumnNumber > 0) + { + writer.Write($", col {warning.ColumnNumber}"); + } + writer.Write(")"); + } + } + writer.WriteLine(); + writer.WriteLine($" {warning.Message}"); + writer.WriteLine(); + break; + } + } + + writer.WriteLine("
"); + writer.WriteLine(); + } + } + else + { + writer.WriteLine("✅ No errors or warnings"); + writer.WriteLine(); + } + } + } + + writer.WriteLine("---"); + writer.WriteLine($"*Build completed at {DateTime.Now:yyyy-MM-dd HH:mm:ss}*"); + } + catch + { + // Silently fail if we can't write to the summary file (permission issues, etc.) + } + } + + /// + /// Writes a diagnostic (error or warning) using GitHub Actions workflow commands. + /// + private void WriteDiagnostic(string type, string? file, int lineNumber, int columnNumber, int endColumnNumber, string? code, string? message) + { + // Format: ::{type} file={name},line={line},col={col},endColumn={endCol},title={title}::{message} + var output = new StringBuilder(); + output.Append("::"); + output.Append(type); + + if (!string.IsNullOrEmpty(file)) + { + output.Append(" file="); + output.Append(EscapeProperty(file!)); + + if (lineNumber > 0) + { + output.Append(",line="); + output.Append(lineNumber); + + if (columnNumber > 0) + { + output.Append(",col="); + output.Append(columnNumber); + + if (endColumnNumber > 0) + { + output.Append(",endColumn="); + output.Append(endColumnNumber); + } + } + } + } + + if (!string.IsNullOrEmpty(code)) + { + output.Append(",title="); + output.Append(EscapeProperty(code!)); + } + + output.Append("::"); + output.Append(EscapeData(message ?? string.Empty)); + output.AppendLine(); + + _write(output.ToString()); + } + + /// + /// Escapes special characters in property values for GitHub Actions workflow commands. + /// + private static string EscapeProperty(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + return value.Replace("%", "%25") + .Replace("\r", "%0D") + .Replace("\n", "%0A") + .Replace(":", "%3A") + .Replace(",", "%2C"); + } + + /// + /// Escapes special characters in message data for GitHub Actions workflow commands. + /// + private static string EscapeData(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + return value.Replace("%", "%25") + .Replace("\r", "%0D") + .Replace("\n", "%0A"); + } + + #endregion +} diff --git a/src/Build/Logging/CICDLogger/GitLab/GitLabForwardingLogger.cs b/src/Build/Logging/CICDLogger/GitLab/GitLabForwardingLogger.cs new file mode 100644 index 00000000000..2fda6ae9106 --- /dev/null +++ b/src/Build/Logging/CICDLogger/GitLab/GitLabForwardingLogger.cs @@ -0,0 +1,76 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Framework; + +#nullable enable + +namespace Microsoft.Build.Logging.CICDLogger.GitLab; + +/// +/// Forwarding logger for GitLab CI that filters and forwards events from build nodes to the central logger. +/// +public sealed class GitLabForwardingLogger : IForwardingLogger +{ + /// + public IEventRedirector? BuildEventRedirector { get; set; } + + /// + public int NodeId { get; set; } + + /// + public LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Normal; + + /// + public string? Parameters { get; set; } + + /// + public void Initialize(IEventSource eventSource, int nodeCount) + { + NodeId = nodeCount; + Initialize(eventSource); + } + + /// + public void Initialize(IEventSource eventSource) + { + // Forward all errors and warnings unconditionally + eventSource.ErrorRaised += ForwardEvent; + eventSource.WarningRaised += ForwardEvent; + + // Forward build lifecycle events + eventSource.BuildStarted += ForwardEvent; + eventSource.BuildFinished += ForwardEvent; + eventSource.ProjectStarted += ForwardEvent; + eventSource.ProjectFinished += ForwardEvent; + + // Forward messages based on importance and verbosity + eventSource.MessageRaised += MessageRaised; + } + + /// + public void Shutdown() + { + } + + private void ForwardEvent(object sender, BuildEventArgs e) + { + BuildEventRedirector?.ForwardEvent(e); + } + + private void MessageRaised(object sender, BuildMessageEventArgs e) + { + // Forward messages based on verbosity + if (Verbosity == LoggerVerbosity.Quiet) + { + return; + } + + if (e.Importance == MessageImportance.High || + (e.Importance == MessageImportance.Normal && Verbosity >= LoggerVerbosity.Normal) || + (e.Importance == MessageImportance.Low && Verbosity >= LoggerVerbosity.Detailed)) + { + BuildEventRedirector?.ForwardEvent(e); + } + } +} diff --git a/src/Build/Logging/CICDLogger/GitLab/GitLabLogger.cs b/src/Build/Logging/CICDLogger/GitLab/GitLabLogger.cs new file mode 100644 index 00000000000..9e182914b0e --- /dev/null +++ b/src/Build/Logging/CICDLogger/GitLab/GitLabLogger.cs @@ -0,0 +1,340 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Build.Framework; + +#nullable enable + +namespace Microsoft.Build.Logging.CICDLogger.GitLab; + +/// +/// Data captured from project evaluation. +/// +public sealed class GitLabEvalData +{ + public string? TargetFramework { get; set; } + public string? RuntimeIdentifier { get; set; } +} + +/// +/// Wrapper for build events with timestamps to maintain ordering. +/// +public sealed class TimestampedBuildEvent +{ + public DateTime Timestamp { get; } + public BuildEventArgs Event { get; } + + public TimestampedBuildEvent(BuildEventArgs evt) + { + Event = evt; + Timestamp = evt.Timestamp; + } +} + +/// +/// Data stored for each project during the build. +/// +public sealed class GitLabProjectData +{ + public string? ProjectFile { get; set; } + public string? TargetFramework { get; set; } + public string? RuntimeIdentifier { get; set; } + public List Events { get; } = new(); + public int SectionId { get; set; } + + public int ErrorCount => Events.Count(e => e.Event is BuildErrorEventArgs); + public int WarningCount => Events.Count(e => e.Event is BuildWarningEventArgs); +} + +/// +/// Data stored for the entire build session. +/// +public sealed class GitLabBuildData +{ + public int TotalErrors { get; set; } + public int TotalWarnings { get; set; } + public int NextSectionId { get; set; } = 1; +} + +/// +/// Logger for GitLab CI that formats build diagnostics using ANSI color codes and collapsible sections. +/// See: https://docs.gitlab.com/ee/ci/jobs/#custom-collapsible-sections +/// +public sealed class GitLabLogger : ProjectTrackingLoggerBase +{ + private Action _write = Console.Out.Write; + + /// + public override LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Normal; + + /// + public override string? Parameters { get; set; } + + /// + /// Detects if GitLab CI environment is active. + /// + /// true if running in GitLab CI; otherwise, false. + public static bool IsEnabled() + { + return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITLAB_CI")); + } + + /// + public override void Shutdown() + { + } + + #region Abstract method implementations + + protected override GitLabEvalData CreateEvalData(ProjectEvaluationFinishedEventArgs e) + { + var evalData = new GitLabEvalData(); + + // Extract target framework and runtime identifier from evaluation properties + if (e.Properties != null) + { + foreach (var item in e.Properties) + { + if (item is System.Collections.DictionaryEntry kvp) + { + var key = kvp.Key as string; + if (key == "TargetFramework") + { + evalData.TargetFramework = kvp.Value?.ToString(); + } + else if (key == "RuntimeIdentifier") + { + evalData.RuntimeIdentifier = kvp.Value?.ToString(); + } + } + } + } + + return evalData; + } + + protected override GitLabProjectData? CreateProjectData(GitLabEvalData evalData, ProjectStartedEventArgs e) + { + return new GitLabProjectData + { + ProjectFile = e.ProjectFile, + TargetFramework = evalData?.TargetFramework, + RuntimeIdentifier = evalData?.RuntimeIdentifier + }; + } + + protected override GitLabBuildData CreateBuildData(BuildStartedEventArgs e) + { + return new GitLabBuildData(); + } + + #endregion + + #region Event handlers + + protected override void OnErrorRaised(BuildErrorEventArgs e, GitLabProjectData? projectData, GitLabBuildData buildData) + { + buildData.TotalErrors++; + + if (projectData != null) + { + // Buffer error for output at project finished + projectData.Events.Add(new TimestampedBuildEvent(e)); + } + else + { + // No project context, write immediately + WriteDiagnostic("ERROR", "\x1b[31m", e.File, e.LineNumber, e.ColumnNumber, e.Code, e.Message); + } + } + + protected override void OnWarningRaised(BuildWarningEventArgs e, GitLabProjectData? projectData, GitLabBuildData buildData) + { + buildData.TotalWarnings++; + + if (projectData != null) + { + // Buffer warning for output at project finished + projectData.Events.Add(new TimestampedBuildEvent(e)); + } + else + { + // No project context, write immediately + WriteDiagnostic("WARNING", "\x1b[33m", e.File, e.LineNumber, e.ColumnNumber, e.Code, e.Message); + } + } + + protected override void OnMessageRaised(BuildMessageEventArgs e, GitLabProjectData? projectData, GitLabBuildData buildData) + { + if (Verbosity == LoggerVerbosity.Quiet) + { + return; + } + + // First question: should I care about this message? + bool shouldLog = e.Importance == MessageImportance.High || + (e.Importance == MessageImportance.Normal && Verbosity >= LoggerVerbosity.Normal) || + (e.Importance == MessageImportance.Low && Verbosity >= LoggerVerbosity.Detailed); + + if (!shouldLog) + { + return; + } + + // Second question: do I log it now or at the end of the project build? + if (projectData != null) + { + // Buffer for output at project finished + projectData.Events.Add(new TimestampedBuildEvent(e)); + } + else + { + // No project context, write immediately + _write(e.Message ?? string.Empty); + _write(Environment.NewLine); + } + } + + protected override void OnProjectStarted(ProjectStartedEventArgs e, GitLabEvalData evalData, GitLabProjectData projectData, GitLabBuildData buildData) + { + // Assign section ID when project starts + projectData.SectionId = buildData.NextSectionId++; + } + + protected override void OnProjectFinished(ProjectFinishedEventArgs e, GitLabProjectData projectData, GitLabBuildData buildData) + { + if (Verbosity >= LoggerVerbosity.Normal) + { + // Build project header with context information + var headerText = "Building "; + headerText += projectData.ProjectFile ?? e.ProjectFile ?? "project"; + + if (!string.IsNullOrEmpty(projectData.TargetFramework)) + { + headerText += " ("; + headerText += projectData.TargetFramework; + + if (!string.IsNullOrEmpty(projectData.RuntimeIdentifier)) + { + headerText += " | "; + headerText += projectData.RuntimeIdentifier; + } + + headerText += ")"; + } + + // Add success/failure status + if (!e.Succeeded) + { + headerText += " - Failed"; + } + else if (projectData.ErrorCount > 0 || projectData.WarningCount > 0) + { + headerText += $" - {projectData.ErrorCount} error(s), {projectData.WarningCount} warning(s)"; + } + + // Use collapsible sections in GitLab CI + // Format: \e[0Ksection_start:TIMESTAMP:SECTION_NAME\r\e[0K + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sectionName = $"build_project_{projectData.SectionId}"; + + _write($"\x1b[0Ksection_start:{timestamp}:{sectionName}\r\x1b[0K"); + _write($"\x1b[36m{headerText}\x1b[0m"); // Cyan color for header + _write(Environment.NewLine); + + // Output all events in timestamp order + foreach (var timestampedEvent in projectData.Events.OrderBy(e => e.Timestamp)) + { + switch (timestampedEvent.Event) + { + case BuildErrorEventArgs error: + WriteDiagnostic("ERROR", "\x1b[31m", error.File, error.LineNumber, error.ColumnNumber, error.Code, error.Message); + break; + case BuildWarningEventArgs warning: + WriteDiagnostic("WARNING", "\x1b[33m", warning.File, warning.LineNumber, warning.ColumnNumber, warning.Code, warning.Message); + break; + case BuildMessageEventArgs message: + _write(message.Message ?? string.Empty); + _write(Environment.NewLine); + break; + } + } + + // End collapsible section + // Format: \e[0Ksection_end:TIMESTAMP:SECTION_NAME\r\e[0K + timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + _write($"\x1b[0Ksection_end:{timestamp}:{sectionName}\r\x1b[0K"); + } + } + + protected override void OnBuildFinished(BuildFinishedEventArgs e, GitLabProjectData[] projectData, GitLabBuildData buildData) + { + if (Verbosity >= LoggerVerbosity.Minimal) + { + if (e.Succeeded) + { + _write("\x1b[32m"); // Green + _write($"Build succeeded. {buildData.TotalWarnings} warning(s)"); + } + else + { + _write("\x1b[31m"); // Red + _write($"Build failed. {buildData.TotalErrors} error(s), {buildData.TotalWarnings} warning(s)"); + } + _write("\x1b[0m"); // Reset color + _write(Environment.NewLine); + } + } + + #endregion + + #region Helper methods + + /// + /// Writes a diagnostic (error or warning) using GitLab formatting. + /// + private void WriteDiagnostic(string type, string colorCode, string? file, int lineNumber, int columnNumber, string? code, string? message) + { + // GitLab uses ANSI color codes for formatting + // Only colorize the type for better legibility + _write(colorCode); // Color code (red for errors, yellow for warnings) + _write(type); + _write("\x1b[0m"); // Reset color after type + _write(": "); + + if (!string.IsNullOrEmpty(file)) + { + _write(file!); + + if (lineNumber > 0) + { + _write("("); + _write(lineNumber.ToString()); + + if (columnNumber > 0) + { + _write(","); + _write(columnNumber.ToString()); + } + + _write(")"); + } + + _write(": "); + } + + if (!string.IsNullOrEmpty(code)) + { + _write(code!); + _write(": "); + } + + _write(message ?? string.Empty); + _write(Environment.NewLine); + } + + #endregion +} diff --git a/src/Build/Logging/ProjectTrackingLoggerBase.cs b/src/Build/Logging/ProjectTrackingLoggerBase.cs new file mode 100644 index 00000000000..375257ba117 --- /dev/null +++ b/src/Build/Logging/ProjectTrackingLoggerBase.cs @@ -0,0 +1,430 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Build.Framework; + +namespace Microsoft.Build.Logging; + +/// +/// A wrapper over the project context ID passed to us in logger events. +/// +internal record struct ProjectContext(int Id) +{ + public ProjectContext(BuildEventContext context) + : this(context.ProjectContextId) + { + } +} + +/// +/// A wrapper over the evaluation context ID passed to us in logger events. +/// +internal record struct EvalContext(int Id) +{ + public EvalContext(BuildEventContext context) + : this(context.EvaluationId) + { + } +} + +/// +/// Base class that tracks build state (evaluation data, node status, completed project data, and whole-build data) during builds. +/// Subclasses can specialize the type of data tracked for each area and implement rendering logic. +/// +/// Data gathered/projected for each evaluation context +/// Data stored for each live, actively running build worker node +/// Data stored for each completed project context instance +/// Data that is aggregated across the entire build session +public abstract class ProjectTrackingLoggerBase : INodeLogger +{ + + /// + /// Tracks the evaluation data for all evaluations seen so far. + /// + /// + /// Keyed by an ID that gets passed to logger callbacks, this allows us to quickly look up the corresponding evaluation. + /// + private readonly Dictionary _evaluationDataByEvalId = new(); + + /// + /// Tracks the status of all relevant projects seen so far. + /// + /// + /// Keyed by an ID that gets passed to logger callbacks, this allows us to quickly look up the corresponding project. + /// + private readonly Dictionary _projectDataByProjectContextId = new(); + + /// + /// Tracks build-level data for the entire build session. + /// + private TBuildData? _buildData; + + #region INodeLogger implementation + + /// + public abstract LoggerVerbosity Verbosity { get; set; } + + /// + public abstract string? Parameters { get; set; } + + /// + /// The number of nodes in the build. Handles the case where MSBUILDNOINPROCNODE is set by reserving an extra slot. + /// + protected int NodeCount { get; private set; } + + /// + public virtual void Initialize(IEventSource eventSource, int nodeCount) + { + // When MSBUILDNOINPROCNODE enabled, NodeId's reported by build start with 2. We need to reserve an extra spot for this case. + NodeCount = nodeCount + 1; + + Initialize(eventSource); + } + + /// + public virtual void Initialize(IEventSource eventSource) + { + eventSource.BuildStarted += BuildStartedHandler; + eventSource.BuildFinished += BuildFinishedHandler; + eventSource.ProjectStarted += ProjectStartedHandler; + eventSource.ProjectFinished += ProjectFinishedHandler; + eventSource.TargetStarted += TargetStartedHandler; + eventSource.TargetFinished += TargetFinishedHandler; + eventSource.TaskStarted += TaskStartedHandler; + eventSource.StatusEventRaised += StatusEventRaisedHandler; + eventSource.MessageRaised += MessageRaisedHandler; + eventSource.WarningRaised += WarningRaisedHandler; + eventSource.ErrorRaised += ErrorRaisedHandler; + + if (eventSource is IEventSource3 eventSource3) + { + eventSource3.IncludeTaskInputs(); + } + + if (eventSource is IEventSource4 eventSource4) + { + eventSource4.IncludeEvaluationPropertiesAndItems(); + } + } + + /// + public abstract void Shutdown(); + + #endregion + + #region Logger callbacks + + /// + /// The callback. + /// + private void BuildStartedHandler(object sender, BuildStartedEventArgs e) + { + _buildData = CreateBuildData(e); + OnBuildStarted(e, _buildData); + } + + /// + /// The callback. + /// + private void BuildFinishedHandler(object sender, BuildFinishedEventArgs e) + { + OnBuildFinished(e, _projectDataByProjectContextId.Values.ToArray(), _buildData!); + + // Clear tracking data + _projectDataByProjectContextId.Clear(); + _evaluationDataByEvalId.Clear(); + _buildData = default; + } + + /// + /// The callback. + /// + private void StatusEventRaisedHandler(object sender, BuildStatusEventArgs e) + { + switch (e) + { + case BuildCanceledEventArgs cancelEvent: + OnBuildCanceled(cancelEvent); + break; + case ProjectEvaluationStartedEventArgs: + break; + case ProjectEvaluationFinishedEventArgs evalFinish: + CaptureEvalContext(evalFinish); + break; + } + } + + /// + /// The callback. + /// + private void ProjectStartedHandler(object sender, ProjectStartedEventArgs e) + { + if (e.BuildEventContext is null) + { + return; + } + + ProjectContext projectContext = new(e.BuildEventContext); + EvalContext evalContext = new(e.BuildEventContext); + + // Get eval data for this project + if (_evaluationDataByEvalId.TryGetValue(evalContext, out TEvalData? evalData)) + { + // Create project data using the eval data + TProjectData? projectData = CreateProjectData(evalData, e); + if (projectData != null) + { + _projectDataByProjectContextId[projectContext] = projectData; + OnProjectStarted(e, evalData, projectData!, _buildData!); + } + } + } + + /// + /// The callback. + /// + private void ProjectFinishedHandler(object sender, ProjectFinishedEventArgs e) + { + var buildEventContext = e.BuildEventContext; + if (buildEventContext is null) + { + return; + } + + ProjectContext projectContext = new(buildEventContext); + + // Get project data + if (_projectDataByProjectContextId.TryGetValue(projectContext, out var projectData)) + { + OnProjectFinished(e, projectData, _buildData!); + } + } + + /// + /// The callback. + /// + private void TargetStartedHandler(object sender, TargetStartedEventArgs e) + { + var buildEventContext = e.BuildEventContext; + if (buildEventContext is not null) + { + ProjectContext projectContext = new(buildEventContext); + if (_projectDataByProjectContextId.TryGetValue(projectContext, out TProjectData? projectData)) + { + OnTargetStarted(e, projectData, _buildData!); + } + } + } + + /// + /// The callback. + /// + private void TargetFinishedHandler(object sender, TargetFinishedEventArgs e) + { + var buildEventContext = e.BuildEventContext; + if (buildEventContext is null) + { + return; + } + + ProjectContext projectContext = new(buildEventContext); + if (_projectDataByProjectContextId.TryGetValue(projectContext, out var projectData)) + { + OnTargetFinished(e, projectData, _buildData!); + } + } + + /// + /// The callback. + /// + private void TaskStartedHandler(object sender, TaskStartedEventArgs e) + { + var buildEventContext = e.BuildEventContext; + if (buildEventContext is not null) + { + ProjectContext projectContext = new(buildEventContext); + if (_projectDataByProjectContextId.TryGetValue(projectContext, out var projectData)) + { + OnTaskStarted(e, projectData, _buildData!); + } + } + } + + /// + /// The callback. + /// + private void MessageRaisedHandler(object sender, BuildMessageEventArgs e) + { + var buildEventContext = e.BuildEventContext; + if (buildEventContext is null) + { + return; + } + + ProjectContext projectContext = new(buildEventContext); + TProjectData? projectData = default; + _projectDataByProjectContextId.TryGetValue(projectContext, out projectData); + OnMessageRaised(e, projectData, _buildData!); + } + + /// + /// The callback. + /// + private void WarningRaisedHandler(object sender, BuildWarningEventArgs e) + { + BuildEventContext? buildEventContext = e.BuildEventContext; + if (buildEventContext is null) + { + OnWarningRaised(e, default, _buildData!); + return; + } + + ProjectContext projectContext = new(buildEventContext); + TProjectData? projectData = default; + _projectDataByProjectContextId.TryGetValue(projectContext, out projectData); + OnWarningRaised(e, projectData, _buildData!); + } + + /// + /// The callback. + /// + private void ErrorRaisedHandler(object sender, BuildErrorEventArgs e) + { + BuildEventContext? buildEventContext = e.BuildEventContext; + if (buildEventContext is null) + { + OnErrorRaised(e, default, _buildData!); + return; + } + + ProjectContext projectContext = new(buildEventContext); + TProjectData? projectData = default; + _projectDataByProjectContextId.TryGetValue(projectContext, out projectData); + OnErrorRaised(e, projectData, _buildData!); + } + + #endregion + + #region Protected helpers + + protected int? GetNodeIdForEvent(BuildEventArgs args) => args?.BuildEventContext is null ? null : NodeIndexForContext(args.BuildEventContext); + + #endregion + + #region Private helpers + + private int NodeIndexForContext(BuildEventContext context) + { + // Node IDs reported by the build are 1-based. + return context.NodeId - 1; + } + + /// + /// Captures evaluation context data from the evaluation finished event. + /// + private void CaptureEvalContext(ProjectEvaluationFinishedEventArgs evalFinish) + { + var buildEventContext = evalFinish.BuildEventContext; + if (buildEventContext is null) + { + return; + } + + EvalContext evalContext = new(buildEventContext); + + if (!_evaluationDataByEvalId.ContainsKey(evalContext)) + { + TEvalData evalData = CreateEvalData(evalFinish); + _evaluationDataByEvalId[evalContext] = evalData; + } + } + + #endregion + + #region Abstract methods - must be implemented by subclasses + + /// + /// Creates evaluation data from the evaluation finished event. + /// + /// The evaluation finished event args. + /// The evaluation data to store, or null to not track this evaluation. + protected abstract TEvalData CreateEvalData(ProjectEvaluationFinishedEventArgs e); + + /// + /// Creates project data from the project started event and evaluation data. + /// + /// The evaluation data for this project. + /// The project started event args. + /// The project data to store, or null to not track this project. + protected abstract TProjectData? CreateProjectData(TEvalData evalData, ProjectStartedEventArgs e); + + /// + /// Creates build data when the build starts. + /// + /// The build started event args. + /// The build data to track for this build session. + protected abstract TBuildData CreateBuildData(BuildStartedEventArgs e); + + #endregion + + #region Virtual methods - can be overridden by subclasses + + /// + /// Called when the build starts. + /// + protected virtual void OnBuildStarted(BuildStartedEventArgs e, TBuildData buildData) { } + + /// + /// Called when the build finishes. + /// + protected virtual void OnBuildFinished(BuildFinishedEventArgs e, TProjectData[] projectData, TBuildData buildData) { } + + /// + /// Called when the build is canceled. + /// + protected virtual void OnBuildCanceled(BuildCanceledEventArgs e) { } + + /// + /// Called when a project starts. + /// + protected virtual void OnProjectStarted(ProjectStartedEventArgs e, TEvalData evalData, TProjectData projectData, TBuildData buildData) { } + + /// + /// Called when a project finishes. + /// + protected virtual void OnProjectFinished(ProjectFinishedEventArgs e, TProjectData projectData, TBuildData buildData) { } + + /// + /// Called when a target starts. + /// + protected virtual void OnTargetStarted(TargetStartedEventArgs e, TProjectData projectData, TBuildData buildData) { } + + /// + /// Called when a target finishes. + /// + protected virtual void OnTargetFinished(TargetFinishedEventArgs e, TProjectData projectData, TBuildData buildData) { } + + /// + /// Called when a task starts. + /// + protected virtual void OnTaskStarted(TaskStartedEventArgs e, TProjectData projectData, TBuildData buildData) { } + + /// + /// Called when a message is raised. + /// + protected virtual void OnMessageRaised(BuildMessageEventArgs e, TProjectData? projectData, TBuildData buildData) { } + + /// + /// Called when a warning is raised. + /// + protected virtual void OnWarningRaised(BuildWarningEventArgs e, TProjectData? projectData, TBuildData buildData) { } + + /// + /// Called when an error is raised. + /// + protected virtual void OnErrorRaised(BuildErrorEventArgs e, TProjectData? projectData, TBuildData buildData) { } + + #endregion +} \ No newline at end of file diff --git a/src/Build/Logging/TerminalLogger/StopwatchAbstraction.cs b/src/Build/Logging/TerminalLogger/StopwatchAbstraction.cs index c4f72f630de..d6c3920910c 100644 --- a/src/Build/Logging/TerminalLogger/StopwatchAbstraction.cs +++ b/src/Build/Logging/TerminalLogger/StopwatchAbstraction.cs @@ -3,7 +3,7 @@ namespace Microsoft.Build.Logging; -internal abstract class StopwatchAbstraction +public abstract class StopwatchAbstraction { public abstract void Start(); public abstract void Stop(); diff --git a/src/Build/Logging/TerminalLogger/TerminalBuildData.cs b/src/Build/Logging/TerminalLogger/TerminalBuildData.cs new file mode 100644 index 00000000000..9ef619c8443 --- /dev/null +++ b/src/Build/Logging/TerminalLogger/TerminalBuildData.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Build.Logging; + +/// +/// Tracks build-level data for the TerminalLogger across an entire build session. +/// +public sealed class TerminalBuildData +{ + /// + /// The timestamp of the build start event. + /// + public DateTime BuildStartTime { get; set; } + + /// + /// Number of build errors encountered during the build. + /// + public int BuildErrorsCount { get; set; } + + /// + /// Number of build warnings encountered during the build. + /// + public int BuildWarningsCount { get; set; } + + /// + /// The project build context corresponding to the Restore initial target, or null if the build is currently not restoring. + /// + public int? RestoreContext { get; set; } + + /// + /// True if restore failed and this failure has already been reported. + /// + public bool RestoreFailed { get; set; } + + /// + /// True if restore happened and finished. + /// + public bool RestoreFinished { get; set; } + + /// + /// Initializes a new instance of TerminalBuildData. + /// + /// The timestamp when the build started. + public TerminalBuildData(DateTime buildStartTime) + { + BuildStartTime = buildStartTime; + } +} \ No newline at end of file diff --git a/src/Build/Logging/TerminalLogger/TerminalBuildMessage.cs b/src/Build/Logging/TerminalLogger/TerminalBuildMessage.cs index 8e90b6f85e2..1097ff85b61 100644 --- a/src/Build/Logging/TerminalLogger/TerminalBuildMessage.cs +++ b/src/Build/Logging/TerminalLogger/TerminalBuildMessage.cs @@ -6,5 +6,5 @@ namespace Microsoft.Build.Logging; /// /// Represents a piece of diagnostic output (message/warning/error). /// -internal record struct TerminalBuildMessage(TerminalMessageSeverity Severity, string Message) +public record struct TerminalBuildMessage(TerminalMessageSeverity Severity, string Message) { } diff --git a/src/Build/Logging/TerminalLogger/TerminalLogger.cs b/src/Build/Logging/TerminalLogger/TerminalLogger.cs index b64fefb705e..ef9a99704c8 100644 --- a/src/Build/Logging/TerminalLogger/TerminalLogger.cs +++ b/src/Build/Logging/TerminalLogger/TerminalLogger.cs @@ -30,7 +30,7 @@ namespace Microsoft.Build.Logging; /// /// Uses ANSI/VT100 control codes to erase and overwrite lines as the build is progressing. /// -public sealed partial class TerminalLogger : INodeLogger +public sealed partial class TerminalLogger : ProjectTrackingLoggerBase { private const string FilePathPattern = " -> "; @@ -42,27 +42,7 @@ public sealed partial class TerminalLogger : INodeLogger private static readonly string[] newLineStrings = { "\r\n", "\n" }; - /// - /// A wrapper over the project context ID passed to us in logger events. - /// - internal record struct ProjectContext(int Id) - { - public ProjectContext(BuildEventContext context) - : this(context.ProjectContextId) - { - } - } - - /// - /// A wrapper over the evaluation context ID passed to us in logger events. - /// - internal record struct EvalContext(int Id) - { - public EvalContext(BuildEventContext context) - : this(context.EvaluationId) - { - } - } + // ProjectContext and EvalContext are now inherited from BuildTrackerLogger private readonly record struct TestSummary(int Total, int Passed, int Skipped, int Failed); @@ -80,31 +60,6 @@ public EvalContext(BuildEventContext context) internal Func? CreateStopwatch = null; - /// - /// Name of target that identifies the project cache plugin run has just started. - /// - private const string CachePluginStartTarget = "_CachePluginRunStart"; - - /// - /// Protects access to state shared between the logger callbacks and the rendering thread. - /// - private readonly LockType _lock = new LockType(); - - /// - /// A cancellation token to signal the rendering thread that it should exit. - /// - private readonly CancellationTokenSource _cts = new(); - - /// - /// Tracks the status of all relevant projects seen so far. - /// - /// - /// Keyed by an ID that gets passed to logger callbacks, this allows us to quickly look up the corresponding project. - /// - private readonly Dictionary _projects = new(); - - private readonly Dictionary _evals = new(); - /// /// Tracks the work currently being done by build nodes. Null means the node is not doing any work worth reporting. /// @@ -112,43 +67,33 @@ public EvalContext(BuildEventContext context) /// There is no locking around access to this data structure despite it being accessed concurrently by multiple threads. /// However, reads and writes to locations in an array is atomic, so locking is not required. /// - private TerminalNodeStatus?[] _nodes = Array.Empty(); + private TerminalNodeStatus?[] _nodes = []; /// - /// The timestamp of the event. + /// Name of target that identifies the project cache plugin run has just started. /// - private DateTime _buildStartTime; + private const string CachePluginStartTarget = "_CachePluginRunStart"; /// - /// The working directory when the build starts, to trim relative output paths. + /// Protects access to state shared between the logger callbacks and the rendering thread. /// - private readonly string _initialWorkingDirectory = Environment.CurrentDirectory; + private readonly LockType _lock = new LockType(); /// - /// Number of build errors. + /// A cancellation token to signal the rendering thread that it should exit. /// - private int _buildErrorsCount; + private readonly CancellationTokenSource _cts = new(); - /// - /// Number of build warnings. - /// - private int _buildWarningsCount; + // Tracking dictionaries and node array are now inherited from BuildTrackerLogger - /// - /// True if restore failed and this failure has already been reported. - /// - private bool _restoreFailed; + // BuildStartTime is now inherited from BuildTrackerLogger /// - /// True if restore happened and finished. + /// The working directory when the build starts, to trim relative output paths. /// - private bool _restoreFinished = false; + private readonly string _initialWorkingDirectory = Environment.CurrentDirectory; - /// - /// The project build context corresponding to the Restore initial target, or null if the build is currently - /// not restoring. - /// - private ProjectContext? _restoreContext; + // Build error/warning counts and restore state are now inherited from BuildTrackerLogger /// /// The thread that performs periodic refresh of the console output. @@ -347,49 +292,19 @@ private static bool IsTerminalLoggerDisabled(string? value) => #region INodeLogger implementation /// - public LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Minimal; - - /// - public string? Parameters { get; set; } = null; + public override LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Minimal; /// - public void Initialize(IEventSource eventSource, int nodeCount) - { - // When MSBUILDNOINPROCNODE enabled, NodeId's reported by build start with 2. We need to reserve an extra spot for this case. - _nodes = new TerminalNodeStatus[nodeCount + 1]; - - Initialize(eventSource); - } + public override string? Parameters { get; set; } = null; /// - public void Initialize(IEventSource eventSource) + public override void Initialize(IEventSource eventSource) { ParseParameters(); - - eventSource.BuildStarted += BuildStarted; - eventSource.BuildFinished += BuildFinished; - eventSource.ProjectStarted += ProjectStarted; - eventSource.ProjectFinished += ProjectFinished; - eventSource.TargetStarted += TargetStarted; - eventSource.TargetFinished += TargetFinished; - eventSource.TaskStarted += TaskStarted; - eventSource.StatusEventRaised += StatusEventRaised; - eventSource.MessageRaised += MessageRaised; - eventSource.WarningRaised += WarningRaised; - eventSource.ErrorRaised += ErrorRaised; - - if (eventSource is IEventSource3 eventSource3) - { - eventSource3.IncludeTaskInputs(); - } - - if (eventSource is IEventSource4 eventSource4) - { - eventSource4.IncludeEvaluationPropertiesAndItems(); - } + base.Initialize(eventSource); + _nodes = new TerminalNodeStatus[NodeCount]; } - /// /// Parses out the logger parameters from the Parameters string. /// @@ -467,7 +382,7 @@ private bool TryApplyShowCommandLineParameter(string? parameterValue) } /// - public void Shutdown() + public override void Shutdown() { NativeMethodsShared.RestoreConsoleMode(_originalConsoleMode); @@ -489,12 +404,77 @@ public MessageImportance GetMinimumMessageImportance() #endregion - #region Logger callbacks + #region BuildTrackerLogger implementation - /// - /// The callback. - /// - private void BuildStarted(object sender, BuildStartedEventArgs e) + /// + protected override TerminalBuildData CreateBuildData(BuildStartedEventArgs e) + { + return new TerminalBuildData(e.Timestamp); + } + + /// + protected override EvalProjectInfo CreateEvalData(ProjectEvaluationFinishedEventArgs e) + { + string? tfm = null; + string? rid = null; + foreach (var property in e.EnumerateProperties()) + { + if (tfm is not null && rid is not null) + { + // We already have both properties, no need to continue. + break; + } + switch (property.Name) + { + case "TargetFramework": + tfm = property.Value; + break; + case "RuntimeIdentifier": + rid = property.Value; + break; + } + } + return new EvalProjectInfo(e.ProjectFile!, tfm, rid); + } + + /// + protected override TerminalProjectInfo? CreateProjectData(EvalProjectInfo evalData, ProjectStartedEventArgs e) + { + return new TerminalProjectInfo(evalData, CreateStopwatch?.Invoke()); + } + + private TerminalNodeStatus? CreateNodeData(TargetStartedEventArgs e, TerminalProjectInfo projectData) + { + projectData.Stopwatch.Start(); + string projectFile = Path.GetFileNameWithoutExtension(e.ProjectFile); + string targetName = e.TargetName; + + if (targetName == CachePluginStartTarget) + { + projectData.IsCachePluginProject = true; + _hasUsedCache = true; + } + + if (targetName == _testStartTarget) + { + targetName = "Testing"; + _testStartTime = _testStartTime == null + ? e.Timestamp + : e.Timestamp < _testStartTime + ? e.Timestamp : _testStartTime; + projectData.IsTestProject = true; + } + + return new TerminalNodeStatus(projectFile, projectData.TargetFramework, projectData.RuntimeIdentifier, targetName, projectData.Stopwatch); + } + + + #endregion + + #region Logger event overrides + + /// + protected override void OnBuildStarted(BuildStartedEventArgs e, TerminalBuildData buildData) { if (!_manualRefresh && _showNodesDisplay) { @@ -503,18 +483,14 @@ private void BuildStarted(object sender, BuildStartedEventArgs e) _refresher.Start(); } - _buildStartTime = e.Timestamp; - if (Terminal.SupportsProgressReporting && Verbosity != LoggerVerbosity.Quiet) { Terminal.Write(AnsiCodes.SetProgressIndeterminate); } } - /// - /// The callback. - /// - private void BuildFinished(object sender, BuildFinishedEventArgs e) + /// + protected override void OnBuildFinished(BuildFinishedEventArgs e, TerminalProjectInfo[] projectInfos, TerminalBuildData buildData) { _cts.Cancel(); _refresher?.Join(); @@ -524,8 +500,8 @@ private void BuildFinished(object sender, BuildFinishedEventArgs e) { if (Verbosity > LoggerVerbosity.Quiet) { - string duration = (e.Timestamp - _buildStartTime).TotalSeconds.ToString("F1"); - string buildResult = GetBuildResultString(e.Succeeded, _buildErrorsCount, _buildWarningsCount); + string duration = (e.Timestamp - buildData.BuildStartTime).TotalSeconds.ToString("F1"); + string buildResult = GetBuildResultString(e.Succeeded, buildData.BuildErrorsCount, buildData.BuildWarningsCount); Terminal.WriteLine(""); if (_testRunSummaries.Any()) @@ -537,8 +513,8 @@ private void BuildFinished(object sender, BuildFinishedEventArgs e) var testDuration = (_testStartTime != null && _testEndTime != null ? (_testEndTime - _testStartTime).Value.TotalSeconds : 0).ToString("F1"); var colorizeFailed = failed > 0; - var colorizePassed = passed > 0 && _buildErrorsCount == 0 && failed == 0; - var colorizeSkipped = skipped > 0 && skipped == total && _buildErrorsCount == 0 && failed == 0; + var colorizePassed = passed > 0 && buildData.BuildErrorsCount == 0 && failed == 0; + var colorizeSkipped = skipped > 0 && skipped == total && buildData.BuildErrorsCount == 0 && failed == 0; string summaryAndTotalText = ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("TestSummary_BannerAndTotal", total); string failedText = ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("TestSummary_Failed", failed); @@ -555,10 +531,10 @@ private void BuildFinished(object sender, BuildFinishedEventArgs e) if (_showSummary == true) { - RenderBuildSummary(); + RenderBuildSummary(buildData, projectInfos); } - if (_restoreFailed) + if (buildData?.RestoreFailed == true) { Terminal.WriteLine(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("RestoreCompleteWithMessage", buildResult, @@ -582,18 +558,14 @@ private void BuildFinished(object sender, BuildFinishedEventArgs e) Terminal.EndUpdate(); } - _projects.Clear(); _testRunSummaries.Clear(); - _buildErrorsCount = 0; - _buildWarningsCount = 0; - _restoreFailed = false; _testStartTime = null; _testEndTime = null; } - private void RenderBuildSummary() + private void RenderBuildSummary(TerminalBuildData buildData, TerminalProjectInfo[] projectInfos) { - if (_buildErrorsCount == 0 && _buildWarningsCount == 0) + if (buildData.BuildErrorsCount == 0 && buildData.BuildWarningsCount == 0) { // No errors/warnings to display. return; @@ -601,7 +573,7 @@ private void RenderBuildSummary() Terminal.WriteLine(ResourceUtilities.GetResourceString("BuildSummary")); - foreach (TerminalProjectInfo project in _projects.Values.Where(p => p.HasErrorsOrWarnings)) + foreach (TerminalProjectInfo project in projectInfos.Where(p => p.HasErrorsOrWarnings)) { string duration = project.Stopwatch.ElapsedSeconds.ToString("F1"); string buildResult = GetBuildResultString(project.Succeeded, project.ErrorCount, project.WarningCount); @@ -618,62 +590,25 @@ private void RenderBuildSummary() Terminal.WriteLine(string.Empty); } - private void StatusEventRaised(object sender, BuildStatusEventArgs e) + /// + protected override void OnBuildCanceled(BuildCanceledEventArgs e) { - switch (e) - { - case BuildCanceledEventArgs cancelEvent: - RenderImmediateMessage(cancelEvent.Message!); - break; - case ProjectEvaluationStartedEventArgs _evalStart: - break; - case ProjectEvaluationFinishedEventArgs evalFinish: - CaptureEvalContext(evalFinish); - break; - } + RenderImmediateMessage(e.Message!); } - /// - /// The callback. - /// - private void ProjectStarted(object sender, ProjectStartedEventArgs e) + /// + protected override void OnProjectStarted(ProjectStartedEventArgs e, EvalProjectInfo evalData, TerminalProjectInfo projectData, TerminalBuildData buildData) { - if (e.BuildEventContext is null) + // Handle restore case + if (buildData.RestoreContext is null && e.TargetNames == "Restore" && !buildData.RestoreFinished && e.BuildEventContext is not null) { - return; - } - - ProjectContext c = new(e.BuildEventContext); - - if (_restoreContext is null) - { - EvalContext evalContext = new(e.BuildEventContext); - string? targetFramework = null; - string? runtimeIdentifier = null; - if (_evals.TryGetValue(evalContext, out EvalProjectInfo evalInfo)) - { - targetFramework = evalInfo.TargetFramework; - runtimeIdentifier = evalInfo.RuntimeIdentifier; - } - System.Diagnostics.Debug.Assert(evalInfo != default, "EvalProjectInfo should have been captured before ProjectStarted"); - - TerminalProjectInfo projectInfo = new(c, evalInfo, CreateStopwatch?.Invoke()); - _projects[c] = projectInfo; - - // First ever restore in the build is starting. - if (e.TargetNames == "Restore" && !_restoreFinished) - { - _restoreContext = c; - int nodeIndex = NodeIndexForContext(e.BuildEventContext); - _nodes[nodeIndex] = new TerminalNodeStatus(e.ProjectFile!, targetFramework, runtimeIdentifier, "Restore", _projects[c].Stopwatch); - } + buildData.RestoreContext = e.BuildEventContext.ProjectContextId; + StartNode(e, new TerminalNodeStatus(e.ProjectFile!, evalData.TargetFramework, evalData.RuntimeIdentifier, "Restore", projectData.Stopwatch)); } } - /// - /// The callback. - /// - private void ProjectFinished(object sender, ProjectFinishedEventArgs e) + /// + protected override void OnProjectFinished(ProjectFinishedEventArgs e, TerminalProjectInfo? projectData, TerminalBuildData buildData) { var buildEventContext = e.BuildEventContext; if (buildEventContext is null) @@ -682,9 +617,9 @@ private void ProjectFinished(object sender, ProjectFinishedEventArgs e) } // Mark node idle until something uses it again - if (_restoreContext is null) + if (buildData.RestoreContext is null) { - UpdateNodeStatus(buildEventContext, null); + YieldNode(e); } // Continue execution and add project summary to the static part of the Console only if verbosity is higher than Quiet. @@ -693,12 +628,20 @@ private void ProjectFinished(object sender, ProjectFinishedEventArgs e) return; } - ProjectContext c = new(buildEventContext); - - if (_projects.TryGetValue(c, out TerminalProjectInfo? project)) + if (projectData != null) { - project.Succeeded = e.Succeeded; - project.Stopwatch.Stop(); + projectData.Succeeded = e.Succeeded; + projectData.Stopwatch.Stop(); + + // Handle restore completion + if (buildEventContext.ProjectContextId == buildData.RestoreContext) + { + buildData.RestoreContext = null; + buildData.RestoreFinished = true; + buildData.RestoreFailed = !e.Succeeded; + OnRestoreFinished(e, projectData, buildData); + } + lock (_lock) { Terminal.BeginUpdate(); @@ -706,52 +649,26 @@ private void ProjectFinished(object sender, ProjectFinishedEventArgs e) { EraseNodes(); - string duration = project.Stopwatch.ElapsedSeconds.ToString("F1"); - ReadOnlyMemory? outputPath = project.OutputPath; + string duration = projectData.Stopwatch.ElapsedSeconds.ToString("F1"); + ReadOnlyMemory? outputPath = projectData.OutputPath; // Build result. One of 'failed', 'succeeded with warnings', or 'succeeded' depending on the build result and diagnostic messages // reported during build. - string buildResult = GetBuildResultString(project.Succeeded, project.ErrorCount, project.WarningCount); - - // Check if we're done restoring. - if (c == _restoreContext) - { - if (e.Succeeded) - { - if (project.HasErrorsOrWarnings) - { - Terminal.WriteLine(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("RestoreCompleteWithMessage", - buildResult, - duration)); - } - else - { - Terminal.WriteLine(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("RestoreComplete", - duration)); - } - } - else - { - // It will be reported after build finishes. - _restoreFailed = true; - } + string buildResult = GetBuildResultString(projectData.Succeeded, projectData.ErrorCount, projectData.WarningCount); - _restoreContext = null; - _restoreFinished = true; - } // If this was a notable project build, we print it as completed only if it's produced an output or warnings/error. // If this is a test project, print it always, so user can see either a success or failure, otherwise success is hidden // and it is hard to see if project finished, or did not run at all. - else if (project.OutputPath is not null || project.BuildMessages is not null || project.IsTestProject) + if (projectData.OutputPath is not null || projectData.BuildMessages is not null || projectData.IsTestProject) { // Show project build complete and its output - string projectFinishedHeader = GetProjectFinishedHeader(project, buildResult, duration); + string projectFinishedHeader = GetProjectFinishedHeader(projectData, buildResult, duration); Terminal.Write(projectFinishedHeader); // Print the output path as a link if we have it. if (outputPath is { } outputPathSpan) { - (var projectDisplayPath, var urlLink) = DetermineOutputPathToRender(outputPathSpan, _initialWorkingDirectory.AsMemory(), project.SourceRoot); + (var projectDisplayPath, var urlLink) = DetermineOutputPathToRender(outputPathSpan, _initialWorkingDirectory.AsMemory(), projectData.SourceRoot); Terminal.WriteLine(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("ProjectFinished_OutputPath", CreateLink(urlLink, projectDisplayPath.ToString()))); } else @@ -761,16 +678,20 @@ private void ProjectFinished(object sender, ProjectFinishedEventArgs e) } // Print diagnostic output under the Project -> Output line. - if (project.BuildMessages is not null) + if (projectData.BuildMessages is not null) { - foreach (TerminalBuildMessage buildMessage in project.BuildMessages) + foreach (TerminalBuildMessage buildMessage in projectData.BuildMessages) { Terminal.WriteLine($"{DoubleIndentation}{buildMessage.Message}"); } } - _buildErrorsCount += project.ErrorCount; - _buildWarningsCount += project.WarningCount; + // Track errors and warnings in build data + if (buildData != null) + { + buildData.BuildErrorsCount += projectData.ErrorCount; + buildData.BuildWarningsCount += projectData.WarningCount; + } if (_showNodesDisplay) { @@ -785,39 +706,27 @@ private void ProjectFinished(object sender, ProjectFinishedEventArgs e) } } - private void CaptureEvalContext(ProjectEvaluationFinishedEventArgs evalFinish) + private void OnRestoreFinished(ProjectFinishedEventArgs e, TerminalProjectInfo? projectData, TerminalBuildData buildData) { - var buildEventContext = evalFinish.BuildEventContext; - if (buildEventContext is null) + if (Verbosity > LoggerVerbosity.Quiet && projectData != null) { - return; - } - - EvalContext c = new(buildEventContext); + string duration = projectData.Stopwatch.ElapsedSeconds.ToString("F1"); + string buildResult = GetBuildResultString(projectData.Succeeded, projectData.ErrorCount, projectData.WarningCount); - if (!_evals.TryGetValue(c, out EvalProjectInfo _)) - { - string? tfm = null; - string? rid = null; - foreach (var property in evalFinish.EnumerateProperties()) + if (e.Succeeded) { - if (tfm is not null && rid is not null) + if (projectData.HasErrorsOrWarnings) { - // We already have both properties, no need to continue. - break; + Terminal.WriteLine(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("RestoreCompleteWithMessage", + buildResult, + duration)); } - switch (property.Name) + else { - case "TargetFramework": - tfm = property.Value; - break; - case "RuntimeIdentifier": - rid = property.Value; - break; + Terminal.WriteLine(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("RestoreComplete", + duration)); } } - var evalInfo = new EvalProjectInfo(c, evalFinish.ProjectFile!, tfm, rid); - _evals[c] = evalInfo; } } @@ -941,123 +850,64 @@ private static string GetProjectFinishedHeader(TerminalProjectInfo project, stri }; } - /// - /// The callback. - /// - private void TargetStarted(object sender, TargetStartedEventArgs e) + /// + protected override void OnTargetStarted(TargetStartedEventArgs e, TerminalProjectInfo projectData, TerminalBuildData buildData) { - var buildEventContext = e.BuildEventContext; - if (_restoreContext is null && buildEventContext is not null && _projects.TryGetValue(new ProjectContext(buildEventContext), out TerminalProjectInfo? project)) + TerminalNodeStatus? nodeData = CreateNodeData(e, projectData); + if (nodeData != null) { - project.Stopwatch.Start(); - - string projectFile = Path.GetFileNameWithoutExtension(e.ProjectFile); - - string targetName = e.TargetName; - if (targetName == CachePluginStartTarget) - { - project.IsCachePluginProject = true; - _hasUsedCache = true; - } - - if (targetName == _testStartTarget) - { - targetName = "Testing"; - - // Use the minimal start time, so if we run tests in parallel, we can calculate duration - // as this start time, minus time when tests finished. - _testStartTime = _testStartTime == null - ? e.Timestamp - : e.Timestamp < _testStartTime - ? e.Timestamp : _testStartTime; - project.IsTestProject = true; - } - - TerminalNodeStatus nodeStatus = new(projectFile, project.TargetFramework, project.RuntimeIdentifier, targetName, project.Stopwatch); - UpdateNodeStatus(buildEventContext, nodeStatus); + StartNode(e, nodeData); } } - private void UpdateNodeStatus(BuildEventContext buildEventContext, TerminalNodeStatus? nodeStatus) - { - int nodeIndex = NodeIndexForContext(buildEventContext); - _nodes[nodeIndex] = nodeStatus; - } - - /// - /// The callback. Unused. - /// - private void TargetFinished(object sender, TargetFinishedEventArgs e) + /// + protected override void OnTargetFinished(TargetFinishedEventArgs e, TerminalProjectInfo? projectData, TerminalBuildData buildData) { // For cache plugin projects which result in a cache hit, ensure the output path is set // to the item spec corresponding to the GetTargetPath target upon completion. - var buildEventContext = e.BuildEventContext; var targetOutputs = e.TargetOutputs; - if (_restoreContext is not null || buildEventContext is null) + if (projectData is null || targetOutputs is null) { return; } - if (targetOutputs is not null - && _hasUsedCache - && e.TargetName == "GetTargetPath" - && _projects.TryGetValue(new ProjectContext(buildEventContext), out TerminalProjectInfo? project)) + if (_hasUsedCache && e.TargetName == "GetTargetPath" && projectData.IsCachePluginProject) { - if (project is not null && project.IsCachePluginProject) + foreach (ITaskItem output in targetOutputs) { - foreach (ITaskItem output in targetOutputs) - { - project.OutputPath = output.ItemSpec.AsMemory(); - break; - } + projectData.OutputPath = output.ItemSpec.AsMemory(); + break; } } - else if (targetOutputs is not null - && e.TargetName == "InitializeSourceRootMappedPaths" - && _projects.TryGetValue(new ProjectContext(buildEventContext), out project) - && project.SourceRoot is null) + else if (e.TargetName == "InitializeSourceRootMappedPaths" && projectData.SourceRoot is null) { - project.SourceRoot = + projectData.SourceRoot = (targetOutputs as IEnumerable)? .FirstOrDefault(root => !string.IsNullOrEmpty(root.GetMetadata("SourceControl"))) ?.ItemSpec.AsMemory(); } } - /// - /// The callback. - /// - private void TaskStarted(object sender, TaskStartedEventArgs e) + /// + protected override void OnTaskStarted(TaskStartedEventArgs e, TerminalProjectInfo projectData, TerminalBuildData buildData) { - var buildEventContext = e.BuildEventContext; - if (_restoreContext is null && buildEventContext is not null && e.TaskName == "MSBuild") + if (e.TaskName == "MSBuild") { // This will yield the node, so preemptively mark it idle - UpdateNodeStatus(buildEventContext, null); + YieldNode(e); - if (_projects.TryGetValue(new ProjectContext(buildEventContext), out TerminalProjectInfo? project)) - { - project.Stopwatch.Stop(); - } + projectData.Stopwatch.Stop(); } } - /// - /// The callback. - /// - private void MessageRaised(object sender, BuildMessageEventArgs e) + /// + protected override void OnMessageRaised(BuildMessageEventArgs e, TerminalProjectInfo? projectData, TerminalBuildData buildData) { - var buildEventContext = e.BuildEventContext; - if (buildEventContext is null) - { - return; - } - string? message = e.Message; if (message is not null && e.Importance == MessageImportance.High) { - var hasProject = _projects.TryGetValue(new ProjectContext(buildEventContext), out TerminalProjectInfo? project); + var hasProject = projectData != null; // Detect project output path by matching high-importance messages against the "$(MSBuildProjectName) -> ..." // pattern used by the CopyFilesToOutputDirectory target. @@ -1066,10 +916,10 @@ private void MessageRaised(object sender, BuildMessageEventArgs e) { var projectFileName = Path.GetFileName(e.ProjectFile.AsSpan()); if (!projectFileName.IsEmpty && - message.AsSpan().StartsWith(Path.GetFileNameWithoutExtension(projectFileName)) && hasProject) + message.AsSpan().StartsWith(Path.GetFileNameWithoutExtension(projectFileName)) && hasProject && projectData != null) { ReadOnlyMemory outputPath = e.Message.AsMemory().Slice(index + 4); - project!.OutputPath = outputPath; + projectData.OutputPath = outputPath; return; } } @@ -1098,10 +948,9 @@ private void MessageRaised(object sender, BuildMessageEventArgs e) } } - if (hasProject && project!.IsTestProject) + if (hasProject && projectData != null && projectData.IsTestProject) { - var node = _nodes[NodeIndexForContext(buildEventContext)]; - + var node = GetNodeForEvent(e); // Consumes test update messages produced by VSTest and MSTest runner. if (node != null && e is IExtendedBuildEventArgs extendedMessage) { @@ -1112,8 +961,11 @@ private void MessageRaised(object sender, BuildMessageEventArgs e) var indicator = extendedMessage.ExtendedMetadata!["localizedResult"]!; var displayName = extendedMessage.ExtendedMetadata!["displayName"]!; - var status = new TerminalNodeStatus(node.Project, node.TargetFramework, node.RuntimeIdentifier, TerminalColor.Green, indicator, displayName, project.Stopwatch); - UpdateNodeStatus(buildEventContext, status); + var status = new TerminalNodeStatus(node.Project, node.TargetFramework, node.RuntimeIdentifier, TerminalColor.Green, indicator, displayName, projectData.Stopwatch); + if (e.BuildEventContext != null) + { + StartNode(e, status); + } break; } @@ -1122,8 +974,11 @@ private void MessageRaised(object sender, BuildMessageEventArgs e) var indicator = extendedMessage.ExtendedMetadata!["localizedResult"]!; var displayName = extendedMessage.ExtendedMetadata!["displayName"]!; - var status = new TerminalNodeStatus(node.Project, node.TargetFramework, node.RuntimeIdentifier, TerminalColor.Yellow, indicator, displayName, project.Stopwatch); - UpdateNodeStatus(buildEventContext, status); + var status = new TerminalNodeStatus(node.Project, node.TargetFramework, node.RuntimeIdentifier, TerminalColor.Yellow, indicator, displayName, projectData.Stopwatch); + if (e.BuildEventContext != null) + { + StartNode(e, status); + } break; } @@ -1168,9 +1023,9 @@ private void MessageRaised(object sender, BuildMessageEventArgs e) return; } - if (hasProject) + if (hasProject && projectData != null) { - project!.AddBuildMessage(TerminalMessageSeverity.Message, FormatInformationalMessage(e)); + projectData.AddBuildMessage(TerminalMessageSeverity.Message, FormatInformationalMessage(e)); } else { @@ -1194,13 +1049,9 @@ private void MessageRaised(object sender, BuildMessageEventArgs e) } } - /// - /// The callback. - /// - private void WarningRaised(object sender, BuildWarningEventArgs e) + /// + protected override void OnWarningRaised(BuildWarningEventArgs e, TerminalProjectInfo? projectData, TerminalBuildData buildData) { - BuildEventContext? buildEventContext = e.BuildEventContext; - // auth provider messages are 'global' in nature and should be a) immediate reported, and b) not re-reported in the summary. if (IsAuthProviderMessage(e.Message)) { @@ -1208,9 +1059,7 @@ private void WarningRaised(object sender, BuildWarningEventArgs e) return; } - if (buildEventContext is not null - && _projects.TryGetValue(new ProjectContext(buildEventContext), out TerminalProjectInfo? project) - && Verbosity > LoggerVerbosity.Quiet) + if (projectData != null && Verbosity > LoggerVerbosity.Quiet) { // If the warning is not a 'global' auth provider message, but is immediate, we render it immediately // but we don't early return so that the project also tracks it. @@ -1221,17 +1070,17 @@ private void WarningRaised(object sender, BuildWarningEventArgs e) // This is the general case - _most_ warnings are not immediate, so we add them to the project summary // and display them in the per-project and final summary. - project.AddBuildMessage(TerminalMessageSeverity.Warning, FormatWarningMessage(e, TripleIndentation)); + projectData.AddBuildMessage(TerminalMessageSeverity.Warning, FormatWarningMessage(e, TripleIndentation)); } else { // It is necessary to display warning messages reported by MSBuild, - // even if it's not tracked in _projects collection or the verbosity is Quiet. + // even if it's not tracked in projects collection or the verbosity is Quiet. // The idea here (similar to the implementation in ErrorRaised) is that // even in Quiet scenarios we need to show warnings/errors, even if not in // full project-tree view RenderImmediateMessage(FormatWarningMessage(e, Indentation)); - _buildWarningsCount++; + buildData.BuildWarningsCount++; } } @@ -1286,26 +1135,20 @@ private static bool IsAuthProviderMessage(string? message) => } } - /// - /// The callback. - /// - private void ErrorRaised(object sender, BuildErrorEventArgs e) + /// + protected override void OnErrorRaised(BuildErrorEventArgs e, TerminalProjectInfo? projectData, TerminalBuildData buildData) { - BuildEventContext? buildEventContext = e.BuildEventContext; - - if (buildEventContext is not null - && _projects.TryGetValue(new ProjectContext(buildEventContext), out TerminalProjectInfo? project) - && Verbosity > LoggerVerbosity.Quiet) + if (projectData != null && Verbosity > LoggerVerbosity.Quiet) { - project.AddBuildMessage(TerminalMessageSeverity.Error, FormatErrorMessage(e, TripleIndentation)); + projectData.AddBuildMessage(TerminalMessageSeverity.Error, FormatErrorMessage(e, TripleIndentation)); } else { - // It is necessary to display error messages reported by MSBuild, even if it's not tracked in _projects collection or the verbosity is Quiet. + // It is necessary to display error messages reported by MSBuild, even if it's not tracked in projects collection or the verbosity is Quiet. // For nicer formatting, any messages from the engine we strip the file portion from. bool hasMSBuildPlaceholderLocation = e.File.Equals("MSBUILD", StringComparison.Ordinal); RenderImmediateMessage(FormatErrorMessage(e, Indentation, requireFileAndLinePortion: !hasMSBuildPlaceholderLocation)); - _buildErrorsCount++; + buildData.BuildErrorsCount++; } } @@ -1391,6 +1234,35 @@ private void EraseNodes() #region Helpers + private TerminalNodeStatus? GetNodeForEvent(BuildEventArgs e) + { + var node = GetNodeIdForEvent(e); + if (node is int nodeId && _nodes[nodeId] is TerminalNodeStatus status) + { + return status; + } + + return null; + } + + private void StartNode(BuildEventArgs e, TerminalNodeStatus status) + { + var node = GetNodeIdForEvent(e); + if (node is int nodeId) + { + _nodes[nodeId] = status; + } + } + + public void YieldNode(BuildEventArgs e) + { + var node = GetNodeIdForEvent(e); + if (node is int nodeId) + { + _nodes[nodeId] = null; + } + } + /// /// Construct a build result summary string. /// @@ -1437,14 +1309,7 @@ private void RenderImmediateMessage(string message) } } - /// - /// Returns the index corresponding to the given . - /// - private int NodeIndexForContext(BuildEventContext context) - { - // Node IDs reported by the build are 1-based. - return context.NodeId - 1; - } + // NodeIndexForContext is now inherited from base class /// /// Colorizes the filename part of the given path. diff --git a/src/Build/Logging/TerminalLogger/TerminalMessageSeverity.cs b/src/Build/Logging/TerminalLogger/TerminalMessageSeverity.cs index 40fafcea1c6..976db15823f 100644 --- a/src/Build/Logging/TerminalLogger/TerminalMessageSeverity.cs +++ b/src/Build/Logging/TerminalLogger/TerminalMessageSeverity.cs @@ -6,4 +6,4 @@ namespace Microsoft.Build.Logging; /// /// Enumerates the supported message severities. /// -internal enum TerminalMessageSeverity { Message, Warning, Error } +public enum TerminalMessageSeverity { Message, Warning, Error } diff --git a/src/Build/Logging/TerminalLogger/TerminalNodeStatus.cs b/src/Build/Logging/TerminalLogger/TerminalNodeStatus.cs index dd14f8586ee..eb78f3719e3 100644 --- a/src/Build/Logging/TerminalLogger/TerminalNodeStatus.cs +++ b/src/Build/Logging/TerminalLogger/TerminalNodeStatus.cs @@ -11,7 +11,7 @@ namespace Microsoft.Build.Logging; /// /// Encapsulates the per-node data shown in live node output. /// -internal class TerminalNodeStatus +public class TerminalNodeStatus { public string Project { get; } public string? TargetFramework { get; } diff --git a/src/Build/Logging/TerminalLogger/TerminalProjectInfo.cs b/src/Build/Logging/TerminalLogger/TerminalProjectInfo.cs index b69c2c515e9..d1b4944e922 100644 --- a/src/Build/Logging/TerminalLogger/TerminalProjectInfo.cs +++ b/src/Build/Logging/TerminalLogger/TerminalProjectInfo.cs @@ -10,32 +10,25 @@ namespace Microsoft.Build.Logging; /// /// A struct containing relevant evaluation-time data that may not be knowable just from ProjectStart events. /// -/// -/// -/// -/// -internal record struct EvalProjectInfo(TerminalLogger.EvalContext context, string? ProjectFile, string? TargetFramework, string? RuntimeIdentifier) +public record struct EvalProjectInfo(string? ProjectFile, string? TargetFramework, string? RuntimeIdentifier) { - public readonly int Id => context.Id; } /// /// Represents a project being built. /// -internal sealed class TerminalProjectInfo +public sealed class TerminalProjectInfo { private List? _buildMessages; /// /// Initialized a new with the given . /// - /// The ProjectContext of this project execution. /// A subset of the interesting eval-time data for this running project /// A stopwatch to time the build of the project. - public TerminalProjectInfo(TerminalLogger.ProjectContext context, EvalProjectInfo evalInfo, StopwatchAbstraction? stopwatch) + public TerminalProjectInfo(EvalProjectInfo evalInfo, StopwatchAbstraction? stopwatch) { _evalInfo = evalInfo; - _context = context; if (stopwatch is not null) { @@ -48,11 +41,6 @@ public TerminalProjectInfo(TerminalLogger.ProjectContext context, EvalProjectInf } } - /// - /// The int value of the ProjectContext id of this project execution. - /// - public int Id => _context.Id; - /// /// The full path to the project file. /// @@ -82,7 +70,6 @@ public TerminalProjectInfo(TerminalLogger.ProjectContext context, EvalProjectInf /// The runtime identifier of the project or null if platform-agnostic. /// public string? RuntimeIdentifier => _evalInfo.RuntimeIdentifier; - private readonly TerminalLogger.ProjectContext _context; private readonly EvalProjectInfo _evalInfo; /// diff --git a/src/Build/Microsoft.Build.csproj b/src/Build/Microsoft.Build.csproj index d11075fd13b..df088fb435e 100644 --- a/src/Build/Microsoft.Build.csproj +++ b/src/Build/Microsoft.Build.csproj @@ -178,7 +178,9 @@ + + diff --git a/src/BuildCheck.UnitTests/EndToEndTests.cs b/src/BuildCheck.UnitTests/EndToEndTests.cs index d251e8f5ecc..36a6ccad2c3 100644 --- a/src/BuildCheck.UnitTests/EndToEndTests.cs +++ b/src/BuildCheck.UnitTests/EndToEndTests.cs @@ -41,6 +41,14 @@ public EndToEndTests(ITestOutputHelper output) public void Dispose() => _env.Dispose(); + /// + /// Helper method to execute MSBuild with console logger to prevent CI/CD logger auto-detection in test environments + /// + private string ExecBootstrapedMSBuildWithConsoleLogger(string arguments, out bool success, int timeoutMilliseconds) + { + return RunnerUtilities.ExecBootstrapedMSBuild($"{arguments} -logger:ConsoleLogger", out success, timeoutMilliseconds: timeoutMilliseconds); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -52,7 +60,7 @@ public void PropertiesUsageAnalyzerTest(bool buildInOutOfProcessNode) out _, "PropsCheckTest.csproj"); - string output = RunnerUtilities.ExecBootstrapedMSBuild($"{projectFile.Path} -check", out bool success, timeoutMilliseconds: timeoutInMilliseconds); + string output = ExecBootstrapedMSBuildWithConsoleLogger($"{projectFile.Path} -check", out bool success, timeoutInMilliseconds); _env.Output.WriteLine(output); _env.Output.WriteLine("========================="); success.ShouldBeTrue(output); @@ -171,7 +179,7 @@ private EmbedResourceTestOutput RunEmbeddedResourceTest(string resourceXmlToAdd, _env.SetCurrentDirectory(Path.Combine(workFolder.Path, entryProjectName)); - string output = RunnerUtilities.ExecBootstrapedMSBuild("-check -restore /p:WarnOnCultureOverwritten=True /p:RespectCulture=" + (respectCulture ? "True" : "\"\""), out bool success, timeoutMilliseconds: timeoutInMilliseconds); + string output = ExecBootstrapedMSBuildWithConsoleLogger("-check -restore /p:WarnOnCultureOverwritten=True /p:RespectCulture=" + (respectCulture ? "True" : "\"\""), out bool success, timeoutMilliseconds: timeoutInMilliseconds); _env.Output.WriteLine(output); _env.Output.WriteLine("========================="); success.ShouldBeTrue(); @@ -232,7 +240,7 @@ private readonly record struct CopyTestOutput( private CopyTestOutput RunCopyToOutputTest(bool restore, bool skipUnchangedDuringCopy) { - string output = RunnerUtilities.ExecBootstrapedMSBuild($"-check {(restore ? "-restore" : null)} /p:SkipUnchanged={(skipUnchangedDuringCopy ? "True" : "\"\"")}", out bool success, timeoutMilliseconds: timeoutInMilliseconds); + string output = ExecBootstrapedMSBuildWithConsoleLogger($"-check {(restore ? "-restore" : null)} /p:SkipUnchanged={(skipUnchangedDuringCopy ? "True" : "\"\"")}", out bool success, timeoutMilliseconds: timeoutInMilliseconds); _env.Output.WriteLine(output); _env.Output.WriteLine("========================="); success.ShouldBeTrue(); @@ -341,7 +349,7 @@ public void WarningsCountExceedsLimitTest(bool buildInOutOfProcessNode, bool lim _env.SetEnvironmentVariable("MSBUILDDONOTLIMITBUILDCHECKRESULTSNUMBER", "1"); } - string output = RunnerUtilities.ExecBootstrapedMSBuild($"{projectFile.Path} -check", out bool success, timeoutMilliseconds: timeoutInMilliseconds); + string output = ExecBootstrapedMSBuildWithConsoleLogger($"{projectFile.Path} -check", out bool success, timeoutMilliseconds: timeoutInMilliseconds); _env.Output.WriteLine(output); _env.Output.WriteLine("========================="); success.ShouldBeTrue(output); @@ -383,7 +391,7 @@ public void TFMConfusionCheckTest(string tfmString, string cliSuffix, bool shoul _env.SetCurrentDirectory(workFolder.Path); - string output = RunnerUtilities.ExecBootstrapedMSBuild($"-check -restore" + cliSuffix, out bool success, timeoutMilliseconds: timeoutInMilliseconds); + string output = ExecBootstrapedMSBuildWithConsoleLogger($"-check -restore" + cliSuffix, out bool success, timeoutMilliseconds: timeoutInMilliseconds); _env.Output.WriteLine(output); _env.Output.WriteLine("========================="); success.ShouldBeTrue(); @@ -465,7 +473,7 @@ public void TFMinNonSdkCheckTest(string projectContent, bool expectCheckTrigger) _env.SetCurrentDirectory(workFolder.Path); - string output = RunnerUtilities.ExecBootstrapedMSBuild($"-check -restore", out bool success, timeoutMilliseconds: timeoutInMilliseconds); + string output = ExecBootstrapedMSBuildWithConsoleLogger($"-check -restore", out bool success, timeoutMilliseconds: timeoutInMilliseconds); _env.Output.WriteLine(output); _env.Output.WriteLine("========================="); success.ShouldBeTrue(); @@ -488,7 +496,7 @@ public void ConfigChangeReflectedOnReuse() "PropsCheckTest.csproj"); // Build without BuildCheck - no findings should be reported - string output = RunnerUtilities.ExecBootstrapedMSBuild($"{projectFile.Path}", out bool success, timeoutMilliseconds: timeoutInMilliseconds); + string output = ExecBootstrapedMSBuildWithConsoleLogger($"{projectFile.Path}", out bool success, timeoutMilliseconds: timeoutInMilliseconds); _env.Output.WriteLine(output); _env.Output.WriteLine("========================="); success.ShouldBeTrue(output); @@ -497,7 +505,7 @@ public void ConfigChangeReflectedOnReuse() output.ShouldNotContain("BC0203"); // Build with BuildCheck - findings should be reported - output = RunnerUtilities.ExecBootstrapedMSBuild($"{projectFile.Path} -check", out success, timeoutMilliseconds: timeoutInMilliseconds); + output = ExecBootstrapedMSBuildWithConsoleLogger($"{projectFile.Path} -check", out success, timeoutMilliseconds: timeoutInMilliseconds); _env.Output.WriteLine(output); _env.Output.WriteLine("========================="); success.ShouldBeTrue(output); @@ -516,7 +524,7 @@ public void ConfigChangeReflectedOnReuse() File.AppendAllText(editorconfigFile.Path, editorConfigChange); // Build with BuildCheck - findings with new severity should be reported - output = RunnerUtilities.ExecBootstrapedMSBuild($"{projectFile.Path} -check", out success, timeoutMilliseconds: timeoutInMilliseconds); + output = ExecBootstrapedMSBuildWithConsoleLogger($"{projectFile.Path} -check", out success, timeoutMilliseconds: timeoutInMilliseconds); _env.Output.WriteLine(output); _env.Output.WriteLine("========================="); // build should fail due to error checks @@ -526,7 +534,7 @@ public void ConfigChangeReflectedOnReuse() output.ShouldContain("error BC0203"); // Build without BuildCheck - no findings should be reported - output = RunnerUtilities.ExecBootstrapedMSBuild($"{projectFile.Path}", out success, timeoutMilliseconds: timeoutInMilliseconds); + output = ExecBootstrapedMSBuildWithConsoleLogger($"{projectFile.Path}", out success, timeoutMilliseconds: timeoutInMilliseconds); _env.Output.WriteLine(output); _env.Output.WriteLine("========================="); success.ShouldBeTrue(output); @@ -544,7 +552,7 @@ public void SampleCheckIntegrationTest_CheckOnBuild(bool buildInOutOfProcessNode { PrepareSampleProjectsAndConfig(buildInOutOfProcessNode, out TransientTestFile projectFile, new List<(string, string)>() { ("BC0101", "warning") }); - string output = RunnerUtilities.ExecBootstrapedMSBuild( + string output = ExecBootstrapedMSBuildWithConsoleLogger( $"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore" + (checkRequested ? " -check" : string.Empty), out bool success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds); _env.Output.WriteLine(output); @@ -583,7 +591,7 @@ public void SampleCheckIntegrationTest_ReplayBinaryLogOfCheckedBuild(bool buildI var projectDirectory = Path.GetDirectoryName(projectFile.Path); string logFile = _env.ExpectFile(".binlog").Path; - _ = RunnerUtilities.ExecBootstrapedMSBuild( + _ = ExecBootstrapedMSBuildWithConsoleLogger( $"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore {(checkRequested ? "-check" : string.Empty)} -bl:{logFile}", out bool success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds); @@ -592,7 +600,7 @@ public void SampleCheckIntegrationTest_ReplayBinaryLogOfCheckedBuild(bool buildI success.ShouldBeTrue(); } - string output = RunnerUtilities.ExecBootstrapedMSBuild( + string output = ExecBootstrapedMSBuildWithConsoleLogger( $"{logFile} -flp:logfile={Path.Combine(projectDirectory!, "logFile.log")};verbosity=diagnostic", out success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds); @@ -634,7 +642,7 @@ public void EditorConfig_SeverityAppliedCorrectly(string BC0101Severity, string? { PrepareSampleProjectsAndConfig(true, out TransientTestFile projectFile, new List<(string, string)>() { ("BC0101", BC0101Severity) }); - string output = RunnerUtilities.ExecBootstrapedMSBuild( + string output = ExecBootstrapedMSBuildWithConsoleLogger( $"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore -check", out bool success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds); @@ -674,7 +682,7 @@ public void CheckHasAccessToAllConfigs() }, checkCandidatePath)); - string projectCheckBuildLog = RunnerUtilities.ExecBootstrapedMSBuild( + string projectCheckBuildLog = ExecBootstrapedMSBuildWithConsoleLogger( $"{Path.Combine(checkCandidatePath, $"CheckCandidate.csproj")} /m:1 -nr:False -restore -check -verbosity:n", out bool success, timeoutMilliseconds: 1200_0000); success.ShouldBeTrue(); @@ -697,13 +705,13 @@ public void SampleCheckIntegrationTest_CheckOnBinaryLogReplay(bool buildInOutOfP string? projectDirectory = Path.GetDirectoryName(projectFile.Path); string logFile = _env.ExpectFile(".binlog").Path; - _ = RunnerUtilities.ExecBootstrapedMSBuild( + _ = ExecBootstrapedMSBuildWithConsoleLogger( $"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore -bl:{logFile}", out bool success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds); success.ShouldBeTrue(); - string output = RunnerUtilities.ExecBootstrapedMSBuild( + string output = ExecBootstrapedMSBuildWithConsoleLogger( $"{logFile} -flp:logfile={Path.Combine(projectDirectory!, "logFile.log")};verbosity=diagnostic {(checkRequested ? "-check" : string.Empty)}", out success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds); @@ -748,7 +756,7 @@ public void NoEnvironmentVariableProperty_Test(bool? customConfigEnabled, string new List<(string, string)>() { ("BC0103", "error") }, customConfigData); - string output = RunnerUtilities.ExecBootstrapedMSBuild( + string output = ExecBootstrapedMSBuildWithConsoleLogger( $"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore -check", out bool success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds); foreach (string expectedMessage in expectedMessages) @@ -784,7 +792,7 @@ public void NoEnvironmentVariableProperty_Scoping(EvaluationCheckScope scope) new List<(string, string)>() { ("BC0103", "error") }, customConfigData); - string output = RunnerUtilities.ExecBootstrapedMSBuild( + string output = ExecBootstrapedMSBuildWithConsoleLogger( $"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore -check", out bool success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds); if (scope == EvaluationCheckScope.ProjectFileOnly) @@ -808,7 +816,7 @@ public void NoEnvironmentVariableProperty_DeferredProcessing(bool warnAsError, b out TransientTestFile projectFile, new List<(string, string)>() { ("BC0103", "warning") }); - string output = RunnerUtilities.ExecBootstrapedMSBuild( + string output = ExecBootstrapedMSBuildWithConsoleLogger( $"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -restore -check" + (warnAsError ? " /p:warn2err=BC0103" : "") + (warnAsMessage ? " /p:warn2msg=BC0103" : ""), out bool success, false, _env.Output, timeoutMilliseconds: timeoutInMilliseconds); @@ -842,7 +850,7 @@ public void CustomCheckTest_NoEditorConfig(string checkCandidate, string[] expec var checkCandidatePath = Path.Combine(TestAssetsRootPath, checkCandidate); AddCustomDataSourceToNugetConfig(checkCandidatePath); - string projectCheckBuildLog = RunnerUtilities.ExecBootstrapedMSBuild( + string projectCheckBuildLog = ExecBootstrapedMSBuildWithConsoleLogger( $"{Path.Combine(checkCandidatePath, $"{checkCandidate}.csproj")} /m:1 -nr:False -restore -check -verbosity:n", out bool successBuild, timeoutMilliseconds: timeoutInMilliseconds); @@ -882,7 +890,7 @@ public void CustomCheckTest_WithEditorConfig(string checkCandidate, string ruleI ruleToCustomConfig: null, checkCandidatePath)); - string projectCheckBuildLog = RunnerUtilities.ExecBootstrapedMSBuild( + string projectCheckBuildLog = ExecBootstrapedMSBuildWithConsoleLogger( $"{Path.Combine(checkCandidatePath, $"{checkCandidate}.csproj")} /m:1 -nr:False -restore -check -verbosity:n", out bool _, timeoutMilliseconds: timeoutInMilliseconds); projectCheckBuildLog.ShouldContain(expectedMessage); @@ -911,7 +919,7 @@ public void CustomChecksFailGracefully(string ruleId, string friendlyName, strin ruleToCustomConfig: null, checkCandidatePath)); - string projectCheckBuildLog = RunnerUtilities.ExecBootstrapedMSBuild( + string projectCheckBuildLog = ExecBootstrapedMSBuildWithConsoleLogger( $"{Path.Combine(checkCandidatePath, $"{checkCandidate}.csproj")} /m:1 -nr:False -restore -check -verbosity:n", out bool success, timeoutMilliseconds: timeoutInMilliseconds); success.ShouldBeTrue(); @@ -931,7 +939,7 @@ public void DoesNotRunOnRestore(bool buildInOutOfProcessNode) { PrepareSampleProjectsAndConfig(buildInOutOfProcessNode, out TransientTestFile projectFile, new List<(string, string)>() { ("BC0101", "warning") }); - string output = RunnerUtilities.ExecBootstrapedMSBuild( + string output = ExecBootstrapedMSBuildWithConsoleLogger( $"{Path.GetFileName(projectFile.Path)} /m:1 -nr:False -t:restore -check", out bool success, timeoutMilliseconds: timeoutInMilliseconds); diff --git a/src/Framework/Logging/TerminalColor.cs b/src/Framework/Logging/TerminalColor.cs index 10e66d8f719..102eacba511 100644 --- a/src/Framework/Logging/TerminalColor.cs +++ b/src/Framework/Logging/TerminalColor.cs @@ -6,7 +6,7 @@ namespace Microsoft.Build.Framework.Logging; /// /// Enumerates the text colors supported by VT100 terminal. /// -internal enum TerminalColor +public enum TerminalColor { Black = 30, Red = 31, diff --git a/src/MSBuild.UnitTests/AzureDevOpsLogger_Tests.cs b/src/MSBuild.UnitTests/AzureDevOpsLogger_Tests.cs new file mode 100644 index 00000000000..9d7e1475f60 --- /dev/null +++ b/src/MSBuild.UnitTests/AzureDevOpsLogger_Tests.cs @@ -0,0 +1,226 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using Microsoft.Build.Logging.CICDLogger.AzureDevOps; +using Microsoft.Build.Framework; +using Microsoft.Build.UnitTests.Shared; +using Shouldly; +using Xunit; + +#nullable enable + +namespace Microsoft.Build.UnitTests +{ + public class AzureDevOpsLogger_Tests : IEventSource, IDisposable + { + private readonly StringWriter _outputWriter = new(); + private readonly AzureDevOpsLogger _logger; + + public AzureDevOpsLogger_Tests() + { + _logger = new AzureDevOpsLogger(); + _logger.Initialize(this); + } + + public void Dispose() + { + _logger.Shutdown(); + _outputWriter.Dispose(); + } + + [Fact] + public void IsEnabled_WhenTFBuildEnvVarIsSet_ReturnsTrue() + { + using TestEnvironment testEnvironment = TestEnvironment.Create(); + testEnvironment.SetEnvironmentVariable("TF_BUILD", "True"); + + AzureDevOpsLogger.IsEnabled().ShouldBeTrue(); + } + + [Fact] + public void IsEnabled_WhenTFBuildEnvVarIsNotSet_ReturnsFalse() + { + using TestEnvironment testEnvironment = TestEnvironment.Create(); + testEnvironment.SetEnvironmentVariable("TF_BUILD", string.Empty); + + AzureDevOpsLogger.IsEnabled().ShouldBeFalse(); + } + + [Fact] + public void ErrorRaised_WithFileAndLineInfo_FormatsCorrectly() + { + var error = new BuildErrorEventArgs( + subcategory: null, + code: "CS0103", + file: "test.cs", + lineNumber: 10, + columnNumber: 5, + endLineNumber: 10, + endColumnNumber: 15, + message: "The name 'foo' does not exist in the current context", + helpKeyword: null!, + senderName: null); + + ErrorRaised?.Invoke(this, error); + + string output = _outputWriter.ToString(); + output.ShouldContain("##vso[task.logissue type=error;sourcepath=test.cs;linenumber=10;columnnumber=5;code=CS0103]"); + output.ShouldContain("The name 'foo' does not exist in the current context"); + } + + [Fact] + public void ErrorRaised_WithoutFileInfo_FormatsCorrectly() + { + var error = new BuildErrorEventArgs( + subcategory: null, + code: "MSB1234", + file: null, + lineNumber: 0, + columnNumber: 0, + endLineNumber: 0, + endColumnNumber: 0, + message: "General build error", + helpKeyword: null!, + senderName: null); + + ErrorRaised?.Invoke(this, error); + + string output = _outputWriter.ToString(); + output.ShouldContain("##vso[task.logissue type=error;code=MSB1234]General build error"); + } + + [Fact] + public void WarningRaised_WithFileAndLineInfo_FormatsCorrectly() + { + var warning = new BuildWarningEventArgs( + subcategory: null, + code: "CS0168", + file: "test.cs", + lineNumber: 20, + columnNumber: 8, + endLineNumber: 20, + endColumnNumber: 12, + message: "The variable 'bar' is declared but never used", + helpKeyword: null!, + senderName: null); + + WarningRaised?.Invoke(this, warning); + + string output = _outputWriter.ToString(); + output.ShouldContain("##vso[task.logissue type=warning;sourcepath=test.cs;linenumber=20;columnnumber=8;code=CS0168]"); + output.ShouldContain("The variable 'bar' is declared but never used"); + } + + [Fact] + public void MessageRaised_WithHighImportance_OutputsMessage() + { + _logger.Verbosity = LoggerVerbosity.Normal; + + var message = new BuildMessageEventArgs( + message: "Building project...", + helpKeyword: null!, + senderName: null, + importance: MessageImportance.High); + + MessageRaised?.Invoke(this, message); + + string output = _outputWriter.ToString(); + output.ShouldContain("Building project..."); + } + + [Fact] + public void ProjectStarted_CreatesSectionCommand() + { + var projectStarted = new ProjectStartedEventArgs( + message: null!, + helpKeyword: null!, + projectFile: "/src/MyProject.csproj", + targetNames: "Build", + properties: null!, + items: null!); + + ProjectStarted?.Invoke(this, projectStarted); + + string output = _outputWriter.ToString(); + output.ShouldContain("##[section]Building /src/MyProject.csproj"); + } + + [Fact] + public void BuildFinished_WithSuccess_OutputsSuccessMessage() + { + _logger.Verbosity = LoggerVerbosity.Minimal; + + var buildFinished = new BuildFinishedEventArgs( + message: "Build succeeded", + helpKeyword: null!, + succeeded: true); + + BuildFinished?.Invoke(this, buildFinished); + + string output = _outputWriter.ToString(); + output.ShouldContain("Build succeeded."); + } + + [Fact] + public void BuildFinished_WithFailure_OutputsFailureMessageAndTaskComplete() + { + _logger.Verbosity = LoggerVerbosity.Minimal; + + var buildFinished = new BuildFinishedEventArgs( + message: "Build failed", + helpKeyword: null!, + succeeded: false); + + BuildFinished?.Invoke(this, buildFinished); + + string output = _outputWriter.ToString(); + output.ShouldContain("##vso[task.complete result=Failed]Build failed."); + } + + [Fact] + public void EscapeProperty_HandlesSpecialCharacters() + { + var error = new BuildErrorEventArgs( + subcategory: null, + code: "TEST", + file: "file;with;semicolons]and]brackets", + lineNumber: 1, + columnNumber: 1, + endLineNumber: 1, + endColumnNumber: 1, + message: "Test\nwith\nnewlines", + helpKeyword: null!, + senderName: null); + + ErrorRaised?.Invoke(this, error); + + string output = _outputWriter.ToString(); + output.ShouldContain("%3B"); // Escaped semicolon + output.ShouldContain("%5D"); // Escaped bracket + output.ShouldContain("%0A"); // Escaped newline + } + + #region IEventSource implementation + +#pragma warning disable CS0067 + public event BuildMessageEventHandler? MessageRaised; + public event BuildErrorEventHandler? ErrorRaised; + public event BuildWarningEventHandler? WarningRaised; + public event BuildStartedEventHandler? BuildStarted; + public event BuildFinishedEventHandler? BuildFinished; + public event ProjectStartedEventHandler? ProjectStarted; + public event ProjectFinishedEventHandler? ProjectFinished; + public event TargetStartedEventHandler? TargetStarted; + public event TargetFinishedEventHandler? TargetFinished; + public event TaskStartedEventHandler? TaskStarted; + public event TaskFinishedEventHandler? TaskFinished; + public event CustomBuildEventHandler? CustomEventRaised; + public event BuildStatusEventHandler? StatusEventRaised; + public event AnyEventHandler? AnyEventRaised; +#pragma warning restore CS0067 + + #endregion + } +} diff --git a/src/MSBuild.UnitTests/GitHubActionsLogger_Tests.cs b/src/MSBuild.UnitTests/GitHubActionsLogger_Tests.cs new file mode 100644 index 00000000000..dd290eef720 --- /dev/null +++ b/src/MSBuild.UnitTests/GitHubActionsLogger_Tests.cs @@ -0,0 +1,265 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using Microsoft.Build.Logging.CICDLogger.GitHubActions; +using Microsoft.Build.Framework; +using Microsoft.Build.UnitTests.Shared; +using Shouldly; +using Xunit; + +#nullable enable + +namespace Microsoft.Build.UnitTests +{ + public class GitHubActionsLogger_Tests : IEventSource, IDisposable + { + private readonly StringWriter _outputWriter = new(); + private readonly GitHubActionsLogger _logger; + + public GitHubActionsLogger_Tests() + { + _logger = new GitHubActionsLogger(); + _logger.Initialize(this); + } + + public void Dispose() + { + _logger.Shutdown(); + _outputWriter.Dispose(); + } + + [Fact] + public void IsEnabled_WhenGitHubActionsEnvVarIsTrue_ReturnsTrue() + { + using TestEnvironment testEnvironment = TestEnvironment.Create(); + testEnvironment.SetEnvironmentVariable("GITHUB_ACTIONS", "true"); + + GitHubActionsLogger.IsEnabled().ShouldBeTrue(); + } + + [Fact] + public void IsEnabled_WhenGitHubActionsEnvVarIs1_ReturnsTrue() + { + using TestEnvironment testEnvironment = TestEnvironment.Create(); + testEnvironment.SetEnvironmentVariable("GITHUB_ACTIONS", "1"); + + GitHubActionsLogger.IsEnabled().ShouldBeTrue(); + } + + [Fact] + public void IsEnabled_WhenGitHubActionsEnvVarIsNotSet_ReturnsFalse() + { + using TestEnvironment testEnvironment = TestEnvironment.Create(); + testEnvironment.SetEnvironmentVariable("GITHUB_ACTIONS", string.Empty); + + GitHubActionsLogger.IsEnabled().ShouldBeFalse(); + } + + [Fact] + public void ErrorRaised_WithFileAndLineInfo_FormatsCorrectly() + { + var error = new BuildErrorEventArgs( + subcategory: null, + code: "CS0103", + file: "test.cs", + lineNumber: 10, + columnNumber: 5, + endLineNumber: 10, + endColumnNumber: 15, + message: "The name 'foo' does not exist in the current context", + helpKeyword: null!, + senderName: null); + + ErrorRaised?.Invoke(this, error); + + string output = _outputWriter.ToString(); + output.ShouldContain("::error file=test.cs,line=10,col=5,endColumn=15,title=CS0103::The name 'foo' does not exist in the current context"); + } + + [Fact] + public void ErrorRaised_WithoutFileInfo_FormatsCorrectly() + { + var error = new BuildErrorEventArgs( + subcategory: null, + code: "MSB1234", + file: null, + lineNumber: 0, + columnNumber: 0, + endLineNumber: 0, + endColumnNumber: 0, + message: "General build error", + helpKeyword: null!, + senderName: null); + + ErrorRaised?.Invoke(this, error); + + string output = _outputWriter.ToString(); + output.ShouldContain("::error,title=MSB1234::General build error"); + } + + [Fact] + public void WarningRaised_WithFileAndLineInfo_FormatsCorrectly() + { + var warning = new BuildWarningEventArgs( + subcategory: null, + code: "CS0168", + file: "test.cs", + lineNumber: 20, + columnNumber: 8, + endLineNumber: 20, + endColumnNumber: 12, + message: "The variable 'bar' is declared but never used", + helpKeyword: null!, + senderName: null); + + WarningRaised?.Invoke(this, warning); + + string output = _outputWriter.ToString(); + output.ShouldContain("::warning file=test.cs,line=20,col=8,endColumn=12,title=CS0168::The variable 'bar' is declared but never used"); + } + + [Fact] + public void MessageRaised_WithHighImportance_OutputsMessage() + { + _logger.Verbosity = LoggerVerbosity.Normal; + + var message = new BuildMessageEventArgs( + message: "Building project...", + helpKeyword: null!, + senderName: null, + importance: MessageImportance.High); + + MessageRaised?.Invoke(this, message); + + string output = _outputWriter.ToString(); + output.ShouldContain("Building project..."); + } + + [Fact] + public void MessageRaised_WithLowImportanceAndNormalVerbosity_DoesNotOutput() + { + _logger.Verbosity = LoggerVerbosity.Normal; + + var message = new BuildMessageEventArgs( + message: "Low importance message", + helpKeyword: null!, + senderName: null, + importance: MessageImportance.Low); + + MessageRaised?.Invoke(this, message); + + string output = _outputWriter.ToString(); + output.ShouldBeEmpty(); + } + + [Fact] + public void ProjectStarted_CreatesGroupCommand() + { + var projectStarted = new ProjectStartedEventArgs( + message: null!, + helpKeyword: null!, + projectFile: "/src/MyProject.csproj", + targetNames: "Build", + properties: null!, + items: null!); + + ProjectStarted?.Invoke(this, projectStarted); + + string output = _outputWriter.ToString(); + output.ShouldContain("::group::Building /src/MyProject.csproj"); + } + + [Fact] + public void ProjectFinished_CreatesEndGroupCommand() + { + var projectFinished = new ProjectFinishedEventArgs( + message: null!, + helpKeyword: null!, + projectFile: "/src/MyProject.csproj", + succeeded: true); + + ProjectFinished?.Invoke(this, projectFinished); + + string output = _outputWriter.ToString(); + output.ShouldContain("::endgroup::"); + } + + [Fact] + public void BuildFinished_WithSuccess_OutputsSuccessMessage() + { + _logger.Verbosity = LoggerVerbosity.Minimal; + + var buildFinished = new BuildFinishedEventArgs( + message: "Build succeeded", + helpKeyword: null!, + succeeded: true); + + BuildFinished?.Invoke(this, buildFinished); + + string output = _outputWriter.ToString(); + output.ShouldContain("Build succeeded."); + } + + [Fact] + public void BuildFinished_WithFailure_OutputsFailureMessage() + { + _logger.Verbosity = LoggerVerbosity.Minimal; + + var buildFinished = new BuildFinishedEventArgs( + message: "Build failed", + helpKeyword: null!, + succeeded: false); + + BuildFinished?.Invoke(this, buildFinished); + + string output = _outputWriter.ToString(); + output.ShouldContain("Build failed."); + } + + [Fact] + public void EscapeProperty_HandlesSpecialCharacters() + { + var error = new BuildErrorEventArgs( + subcategory: null, + code: "TEST", + file: "file:with:colons,and,commas", + lineNumber: 1, + columnNumber: 1, + endLineNumber: 1, + endColumnNumber: 1, + message: "Test\nwith\nnewlines", + helpKeyword: null!, + senderName: null); + + ErrorRaised?.Invoke(this, error); + + string output = _outputWriter.ToString(); + output.ShouldContain("%3A"); // Escaped colon + output.ShouldContain("%2C"); // Escaped comma + output.ShouldContain("%0A"); // Escaped newline + } + + #region IEventSource implementation + +#pragma warning disable CS0067 + public event BuildMessageEventHandler? MessageRaised; + public event BuildErrorEventHandler? ErrorRaised; + public event BuildWarningEventHandler? WarningRaised; + public event BuildStartedEventHandler? BuildStarted; + public event BuildFinishedEventHandler? BuildFinished; + public event ProjectStartedEventHandler? ProjectStarted; + public event ProjectFinishedEventHandler? ProjectFinished; + public event TargetStartedEventHandler? TargetStarted; + public event TargetFinishedEventHandler? TargetFinished; + public event TaskStartedEventHandler? TaskStarted; + public event TaskFinishedEventHandler? TaskFinished; + public event CustomBuildEventHandler? CustomEventRaised; + public event BuildStatusEventHandler? StatusEventRaised; + public event AnyEventHandler? AnyEventRaised; +#pragma warning restore CS0067 + + #endregion + } +} diff --git a/src/MSBuild.UnitTests/GitLabLogger_Tests.cs b/src/MSBuild.UnitTests/GitLabLogger_Tests.cs new file mode 100644 index 00000000000..55a2ed12479 --- /dev/null +++ b/src/MSBuild.UnitTests/GitLabLogger_Tests.cs @@ -0,0 +1,251 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using Microsoft.Build.Logging.CICDLogger.GitLab; +using Microsoft.Build.Framework; +using Microsoft.Build.UnitTests.Shared; +using Shouldly; +using Xunit; + +#nullable enable + +namespace Microsoft.Build.UnitTests +{ + public class GitLabLogger_Tests : IEventSource, IDisposable + { + private readonly StringWriter _outputWriter = new(); + private readonly GitLabLogger _logger; + + public GitLabLogger_Tests() + { + _logger = new GitLabLogger(); + _logger.Initialize(this); + } + + public void Dispose() + { + _logger.Shutdown(); + _outputWriter.Dispose(); + } + + [Fact] + public void IsEnabled_WhenGitLabCIEnvVarIsSet_ReturnsTrue() + { + using TestEnvironment testEnvironment = TestEnvironment.Create(); + testEnvironment.SetEnvironmentVariable("GITLAB_CI", "true"); + + GitLabLogger.IsEnabled().ShouldBeTrue(); + } + + [Fact] + public void IsEnabled_WhenGitLabCIEnvVarIsNotSet_ReturnsFalse() + { + using TestEnvironment testEnvironment = TestEnvironment.Create(); + testEnvironment.SetEnvironmentVariable("GITLAB_CI", string.Empty); + + GitLabLogger.IsEnabled().ShouldBeFalse(); + } + + [Fact] + public void ErrorRaised_WithFileAndLineInfo_FormatsWithRedColor() + { + var error = new BuildErrorEventArgs( + subcategory: null, + code: "CS0103", + file: "test.cs", + lineNumber: 10, + columnNumber: 5, + endLineNumber: 10, + endColumnNumber: 15, + message: "The name 'foo' does not exist in the current context", + helpKeyword: null!, + senderName: null); + + ErrorRaised?.Invoke(this, error); + + string output = _outputWriter.ToString(); + output.ShouldContain("\x1b[31m"); // Red color code + output.ShouldContain("ERROR:"); + output.ShouldContain("test.cs(10,5)"); + output.ShouldContain("CS0103"); + output.ShouldContain("The name 'foo' does not exist in the current context"); + output.ShouldContain("\x1b[0m"); // Reset color code + } + + [Fact] + public void ErrorRaised_WithoutFileInfo_FormatsWithRedColor() + { + var error = new BuildErrorEventArgs( + subcategory: null, + code: "MSB1234", + file: null, + lineNumber: 0, + columnNumber: 0, + endLineNumber: 0, + endColumnNumber: 0, + message: "General build error", + helpKeyword: null!, + senderName: null); + + ErrorRaised?.Invoke(this, error); + + string output = _outputWriter.ToString(); + output.ShouldContain("\x1b[31m"); // Red color code + output.ShouldContain("ERROR:"); + output.ShouldContain("MSB1234"); + output.ShouldContain("General build error"); + output.ShouldContain("\x1b[0m"); // Reset color code + } + + [Fact] + public void WarningRaised_WithFileAndLineInfo_FormatsWithYellowColor() + { + var warning = new BuildWarningEventArgs( + subcategory: null, + code: "CS0168", + file: "test.cs", + lineNumber: 20, + columnNumber: 8, + endLineNumber: 20, + endColumnNumber: 12, + message: "The variable 'bar' is declared but never used", + helpKeyword: null!, + senderName: null); + + WarningRaised?.Invoke(this, warning); + + string output = _outputWriter.ToString(); + output.ShouldContain("\x1b[33m"); // Yellow color code + output.ShouldContain("WARNING:"); + output.ShouldContain("test.cs(20,8)"); + output.ShouldContain("CS0168"); + output.ShouldContain("The variable 'bar' is declared but never used"); + output.ShouldContain("\x1b[0m"); // Reset color code + } + + [Fact] + public void MessageRaised_WithHighImportance_OutputsMessage() + { + _logger.Verbosity = LoggerVerbosity.Normal; + + var message = new BuildMessageEventArgs( + message: "Building project...", + helpKeyword: null!, + senderName: null, + importance: MessageImportance.High); + + MessageRaised?.Invoke(this, message); + + string output = _outputWriter.ToString(); + output.ShouldContain("Building project..."); + } + + [Fact] + public void ProjectStarted_CreatesCollapsibleSection() + { + var projectStarted = new ProjectStartedEventArgs( + message: null!, + helpKeyword: null!, + projectFile: "/src/MyProject.csproj", + targetNames: "Build", + properties: null!, + items: null!); + + ProjectStarted?.Invoke(this, projectStarted); + + string output = _outputWriter.ToString(); + output.ShouldContain("\x1b[0Ksection_start:"); // Section start marker + output.ShouldContain("build_project_1"); // Section name + output.ShouldContain("\x1b[36m"); // Cyan color + output.ShouldContain("Building /src/MyProject.csproj"); + output.ShouldContain("\x1b[0m"); // Reset color + } + + [Fact] + public void ProjectFinished_EndsCollapsibleSection() + { + // Start a project first + var projectStarted = new ProjectStartedEventArgs( + message: null!, + helpKeyword: null!, + projectFile: "/src/MyProject.csproj", + targetNames: "Build", + properties: null!, + items: null!); + ProjectStarted?.Invoke(this, projectStarted); + + _outputWriter.GetStringBuilder().Clear(); + + var projectFinished = new ProjectFinishedEventArgs( + message: null!, + helpKeyword: null!, + projectFile: "/src/MyProject.csproj", + succeeded: true); + + ProjectFinished?.Invoke(this, projectFinished); + + string output = _outputWriter.ToString(); + output.ShouldContain("\x1b[0Ksection_end:"); // Section end marker + output.ShouldContain("build_project_1"); // Section name + } + + [Fact] + public void BuildFinished_WithSuccess_OutputsGreenSuccessMessage() + { + _logger.Verbosity = LoggerVerbosity.Minimal; + + var buildFinished = new BuildFinishedEventArgs( + message: "Build succeeded", + helpKeyword: null!, + succeeded: true); + + BuildFinished?.Invoke(this, buildFinished); + + string output = _outputWriter.ToString(); + output.ShouldContain("\x1b[32m"); // Green color code + output.ShouldContain("Build succeeded."); + output.ShouldContain("\x1b[0m"); // Reset color code + } + + [Fact] + public void BuildFinished_WithFailure_OutputsRedFailureMessage() + { + _logger.Verbosity = LoggerVerbosity.Minimal; + + var buildFinished = new BuildFinishedEventArgs( + message: "Build failed", + helpKeyword: null!, + succeeded: false); + + BuildFinished?.Invoke(this, buildFinished); + + string output = _outputWriter.ToString(); + output.ShouldContain("\x1b[31m"); // Red color code + output.ShouldContain("Build failed."); + output.ShouldContain("\x1b[0m"); // Reset color code + } + + #region IEventSource implementation + +#pragma warning disable CS0067 + public event BuildMessageEventHandler? MessageRaised; + public event BuildErrorEventHandler? ErrorRaised; + public event BuildWarningEventHandler? WarningRaised; + public event BuildStartedEventHandler? BuildStarted; + public event BuildFinishedEventHandler? BuildFinished; + public event ProjectStartedEventHandler? ProjectStarted; + public event ProjectFinishedEventHandler? ProjectFinished; + public event TargetStartedEventHandler? TargetStarted; + public event TargetFinishedEventHandler? TargetFinished; + public event TaskStartedEventHandler? TaskStarted; + public event TaskFinishedEventHandler? TaskFinished; + public event CustomBuildEventHandler? CustomEventRaised; + public event BuildStatusEventHandler? StatusEventRaised; + public event AnyEventHandler? AnyEventRaised; +#pragma warning restore CS0067 + + #endregion + } +} diff --git a/src/MSBuild.UnitTests/MSBuildServer_Tests.cs b/src/MSBuild.UnitTests/MSBuildServer_Tests.cs index a820d138bf8..9b422a81735 100644 --- a/src/MSBuild.UnitTests/MSBuildServer_Tests.cs +++ b/src/MSBuild.UnitTests/MSBuildServer_Tests.cs @@ -99,7 +99,7 @@ public void MSBuildServerTest() int pidOfServerProcess = ParseNumber(output, "Server ID is "); pidOfInitialProcess.ShouldNotBe(pidOfServerProcess, "We started a server node to execute the target rather than running it in-proc, so its pid should be different."); - output = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out success, false, _output); + output = ExecMSBuildWithConsoleLogger(project.Path, out success); success.ShouldBeTrue(); int newPidOfInitialProcess = ParseNumber(output, "Process ID is "); newPidOfInitialProcess.ShouldNotBe(pidOfServerProcess, "We started a server node to execute the target rather than running it in-proc, so its pid should be different."); @@ -125,7 +125,7 @@ public void MSBuildServerTest() RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, sleepProject.Path, out _); // Ensure that a new build can still succeed and that its server node is different. - output = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out success, false, _output); + output = ExecMSBuildWithConsoleLogger(project.Path, out success); success.ShouldBeTrue(); newPidOfInitialProcess = ParseNumber(output, "Process ID is "); @@ -152,14 +152,14 @@ public void VerifyMixedLegacyBehavior() pidOfInitialProcess.ShouldNotBe(pidOfServerProcess, "We started a server node to execute the target rather than running it in-proc, so its pid should be different."); Environment.SetEnvironmentVariable("MSBUILDUSESERVER", ""); - output = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out success, false, _output); + output = ExecMSBuildWithConsoleLogger(project.Path, out success); success.ShouldBeTrue(); pidOfInitialProcess = ParseNumber(output, "Process ID is "); int pidOfNewserverProcess = ParseNumber(output, "Server ID is "); pidOfInitialProcess.ShouldBe(pidOfNewserverProcess, "We did not start a server node to execute the target, so its pid should be the same."); Environment.SetEnvironmentVariable("MSBUILDUSESERVER", "1"); - output = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out success, false, _output); + output = ExecMSBuildWithConsoleLogger(project.Path, out success); success.ShouldBeTrue(); pidOfInitialProcess = ParseNumber(output, "Process ID is "); pidOfNewserverProcess = ParseNumber(output, "Server ID is "); @@ -211,13 +211,13 @@ public void BuildsWhileBuildIsRunningOnServer() Environment.SetEnvironmentVariable("MSBUILDUSESERVER", "0"); - output = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out success, false, _output); + output = ExecMSBuildWithConsoleLogger(project.Path, out success); success.ShouldBeTrue(); ParseNumber(output, "Server ID is ").ShouldBe(ParseNumber(output, "Process ID is "), "There should not be a server node for this build."); Environment.SetEnvironmentVariable("MSBUILDUSESERVER", "1"); - output = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out success, false, _output); + output = ExecMSBuildWithConsoleLogger(project.Path, out success); success.ShouldBeTrue(); pidOfServerProcess.ShouldNotBe(ParseNumber(output, "Server ID is "), "The server should be otherwise occupied."); pidOfServerProcess.ShouldNotBe(ParseNumber(output, "Process ID is "), "There should not be a server node for this build."); @@ -347,6 +347,14 @@ public void PropertyMSBuildStartupDirectoryOnServer() output.ShouldContain($@":MSBuildStartupDirectory:{Environment.CurrentDirectory}:"); } + /// + /// Execute MSBuild with explicit console logger to prevent CI/CD logger auto-detection in test environments. + /// + private string ExecMSBuildWithConsoleLogger(string projectPath, out bool success) + { + return RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, $"{projectPath} -logger:ConsoleLogger", out success, false, _output); + } + private int ParseNumber(string searchString, string toFind) { Regex regex = new(@$"{toFind}(\d+)"); diff --git a/src/MSBuild.UnitTests/PerfLog_Tests.cs b/src/MSBuild.UnitTests/PerfLog_Tests.cs index eadb3226199..8add4807b64 100644 --- a/src/MSBuild.UnitTests/PerfLog_Tests.cs +++ b/src/MSBuild.UnitTests/PerfLog_Tests.cs @@ -40,7 +40,7 @@ public void TestPerfLogEnabledProducedLogFile() "); string projectPath = Path.Combine(projectFolder.Path, "ClassLibrary.csproj"); - string msbuildParameters = "\"" + projectPath + "\""; + string msbuildParameters = "\"" + projectPath + "\" -logger:ConsoleLogger"; RunnerUtilities.ExecMSBuild(msbuildParameters, out bool successfulExit); successfulExit.ShouldBeTrue(); @@ -76,7 +76,7 @@ public void TestPerfLogDirectoryGetsCreated() "); string projectPath = Path.Combine(projectFolder.Path, "ClassLibrary.csproj"); - string msbuildParameters = "\"" + projectPath + "\""; + string msbuildParameters = "\"" + projectPath + "\" -logger:ConsoleLogger"; Directory.Exists(perfLogPath).ShouldBeFalse(); diff --git a/src/MSBuild.UnitTests/XMake_Tests.cs b/src/MSBuild.UnitTests/XMake_Tests.cs index f5de4715ccb..947cbd767b3 100644 --- a/src/MSBuild.UnitTests/XMake_Tests.cs +++ b/src/MSBuild.UnitTests/XMake_Tests.cs @@ -3006,7 +3006,9 @@ private string ExecuteMSBuildExeExpectFailure(string projectContents, IDictionar } } - string output = RunnerUtilities.ExecMSBuild($"\"{testProject.ProjectFile}\" {string.Join(" ", arguments)}", out var success, _output); + // Explicitly use console logger to prevent CI/CD logger auto-detection in test environments + string allArguments = $"\"{testProject.ProjectFile}\" -logger:ConsoleLogger {string.Join(" ", arguments)}"; + string output = RunnerUtilities.ExecMSBuild(allArguments, out var success, _output); return (success, output); } diff --git a/src/MSBuild/XMake.cs b/src/MSBuild/XMake.cs index e5e12b80ae2..e7f384292ff 100644 --- a/src/MSBuild/XMake.cs +++ b/src/MSBuild/XMake.cs @@ -36,6 +36,9 @@ using Microsoft.Build.Shared.Debugging; using Microsoft.Build.Shared.FileSystem; using Microsoft.Build.Tasks.AssemblyDependency; +using Microsoft.Build.Logging.CICDLogger.GitHubActions; +using Microsoft.Build.Logging.CICDLogger.AzureDevOps; +using Microsoft.Build.Logging.CICDLogger.GitLab; using BinaryLogger = Microsoft.Build.Logging.BinaryLogger; using ConsoleLogger = Microsoft.Build.Logging.ConsoleLogger; using FileLogger = Microsoft.Build.Logging.FileLogger; @@ -3823,11 +3826,22 @@ private static ILogger[] ProcessLoggingSwitches( // Add any loggers which have been specified on the command line distributedLoggerRecords = ProcessDistributedLoggerSwitch(distributedLoggerSwitchParameters, verbosity); + // Check if we should use a CI/CD provider-specific logger + bool useCICDLogger = false; + if (!useSimpleErrorLogger && !terminalloggerOptIn && !noConsoleLogger) + { + useCICDLogger = TryProcessCICDLogger(distributedLoggerRecords, verbosity, cpuCount, loggers); + } + // Otherwise choose default console logger: None, TerminalLogger, or the older ConsoleLogger if (useSimpleErrorLogger) { loggers.Add(new SimpleErrorLogger()); } + else if (useCICDLogger) + { + // CI/CD logger was already processed + } else if (terminalloggerOptIn) { ProcessTerminalLogger(noConsoleLogger, aggregatedTerminalLoggerParameters, distributedLoggerRecords, verbosity, cpuCount, loggers); @@ -4058,6 +4072,63 @@ private static DistributedLoggerRecord CreateTerminalLoggerForwardingLoggerRecor return new DistributedLoggerRecord(centralLogger, forwardingLoggerDescription); } + /// + /// Attempts to detect and configure a CI/CD provider-specific logger. + /// + /// true if a CI/CD logger was configured; otherwise, false. + private static bool TryProcessCICDLogger( + List distributedLoggerRecords, + LoggerVerbosity verbosity, + int cpuCount, + List loggers) + { + INodeLogger cicdLogger = null; + Type forwardingLoggerType = null; + + // Check for GitHub Actions + if (GitHubActionsLogger.IsEnabled()) + { + cicdLogger = new GitHubActionsLogger { Verbosity = verbosity }; + forwardingLoggerType = typeof(GitHubActionsForwardingLogger); + } + // Check for Azure DevOps + else if (AzureDevOpsLogger.IsEnabled()) + { + cicdLogger = new AzureDevOpsLogger { Verbosity = verbosity }; + forwardingLoggerType = typeof(AzureDevOpsForwardingLogger); + } + // Check for GitLab CI + else if (GitLabLogger.IsEnabled()) + { + cicdLogger = new GitLabLogger { Verbosity = verbosity }; + forwardingLoggerType = typeof(GitLabForwardingLogger); + } + + if (cicdLogger != null && forwardingLoggerType != null) + { + // For single-process builds or when in-proc node is enabled, use the logger directly + if (cpuCount == 1 && !Traits.Instance.InProcNodeDisabled) + { + loggers.Add(cicdLogger); + } + else + { + // For multi-process builds, use distributed logger pattern + LoggerDescription forwardingLoggerDescription = new LoggerDescription( + forwardingLoggerType.FullName, + forwardingLoggerType.Assembly.FullName, + null, + string.Empty, + verbosity); + distributedLoggerRecords.Add(new DistributedLoggerRecord(cicdLogger, forwardingLoggerDescription)); + } + + return true; + } + + return false; + } + /// /// Returns a DistributedLoggerRecord containing this logger and a ConfigurableForwardingLogger. /// Looks at the logger's parameters for any verbosity parameter in order to make sure it is setting up the ConfigurableForwardingLogger