csharp/actions/runner/src/Runner.Worker/Handlers/OutputManager.cs

OutputManager.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Container;
using DTWebApi = GitHub.DistributedTask.WebApi;

namespace GitHub.Runner.Worker.Handlers
{
    public sealed clast OutputManager : IDisposable
    {
        private const string _colorCodePrefix = "\033[";
        private const int _maxAttempts = 3;
        private const string _timeoutKey = "GITHUB_ACTIONS_RUNNER_ISSUE_MATCHER_TIMEOUT";
        private static readonly Regex _colorCodeRegex = new Regex(@"\x0033\[[0-9;]*m?", RegexOptions.Compiled | RegexOptions.CultureInvariant);
        private readonly IActionCommandManager _commandManager;
        private readonly ContainerInfo _container;
        private readonly IExecutionContext _executionContext;
        private readonly int _failsafe = 50;
        private readonly object _matchersLock = new object();
        private readonly TimeSpan _timeout;
        private IssueMatcher[] _matchers = Array.Empty();
        // Mapping that indicates whether a directory belongs to the workflow repository
        private readonly Dictionary _directoryMap = new Dictionary();

        public OutputManager(IExecutionContext executionContext, IActionCommandManager commandManager, ContainerInfo container = null)
        {
            _executionContext = executionContext;
            _commandManager = commandManager;
            _container = container ?? executionContext.Global.Container;

            // Recursion failsafe (test override)
            var failsafeString = Environment.GetEnvironmentVariable("RUNNER_TEST_GET_REPOSITORY_PATH_FAILSAFE");
            if (!string.IsNullOrEmpty(failsafeString))
            {
                _failsafe = int.Parse(failsafeString, NumberStyles.None);
            }

            // Determine the timeout
            var timeoutStr = _executionContext.Global.Variables.Get(_timeoutKey);
            if (string.IsNullOrEmpty(timeoutStr) ||
                !TimeSpan.TryParse(timeoutStr, CultureInfo.InvariantCulture, out _timeout) ||
                _timeout = 0 || line.IndexOf(ActionCommand._commandKey) >= 0))
            {
                // This does not need to be inside of a critical section.
                // The logging queues and command handlers are thread-safe.
                if (_commandManager.TryProcessCommand(_executionContext, line, _container))
                {
                    return;
                }
            }

            // Problem matchers
            if (_matchers.Length > 0)
            {
                // Copy the reference
                var matchers = _matchers;

                // Strip color codes
                var stripped = line.Contains(_colorCodePrefix) ? _colorCodeRegex.Replace(line, string.Empty) : line;

                foreach (var matcher in matchers)
                {
                    IssueMatch match = null;
                    for (var attempt = 1; attempt  !object.ReferenceEquals(x, matcher)))
                        {
                            otherMatcher.Reset();
                        }

                        // Convert to issue
                        var issue = ConvertToIssue(match);

                        if (issue != null)
                        {
                            // Log issue
                            _executionContext.AddIssue(issue, stripped);

                            return;
                        }
                    }
                }
            }

            // Regular output
            _executionContext.Output(line);
        }

        private void OnMatcherChanged(object sender, MatcherChangedEventArgs e)
        {
            // Lock
            lock (_matchersLock)
            {
                var newMatchers = new List();

                // Prepend
                if (e.Config.Patterns.Length > 0)
                {
                    newMatchers.Add(new IssueMatcher(e.Config, _timeout));
                }

                // Add existing non-matching
                newMatchers.AddRange(_matchers.Where(x => !string.Equals(x.Owner, e.Config.Owner, StringComparison.OrdinalIgnoreCase)));

                // Store
                _matchers = newMatchers.ToArray();
            }
        }

        private void Remove(IssueMatcher matcher)
        {
            // Lock
            lock (_matchersLock)
            {
                var newMatchers = new List();

                // Match by object reference, not by owner name
                newMatchers.AddRange(_matchers.Where(x => !object.ReferenceEquals(x, matcher)));

                // Store
                _matchers = newMatchers.ToArray();
            }
        }

        private DTWebApi.Issue ConvertToIssue(IssueMatch match)
        {
            // Validate the message
            if (string.IsNullOrWhiteSpace(match.Message))
            {
                _executionContext.Debug("Skipping logging an issue for the matched line because the message is empty.");
                return null;
            }

            // Validate the severity
            DTWebApi.IssueType issueType;
            if (string.IsNullOrEmpty(match.Severity) || string.Equals(match.Severity, "error", StringComparison.OrdinalIgnoreCase))
            {
                issueType = DTWebApi.IssueType.Error;
            }
            else if (string.Equals(match.Severity, "warning", StringComparison.OrdinalIgnoreCase))
            {
                issueType = DTWebApi.IssueType.Warning;
            }
            else if (string.Equals(match.Severity, "notice", StringComparison.OrdinalIgnoreCase))
            {
                issueType = DTWebApi.IssueType.Notice;
            }
            else
            {
                _executionContext.Debug($"Skipped logging an issue for the matched line because the severity '{match.Severity}' is not supported.");
                return null;
            }

            var issue = new DTWebApi.Issue
            {
                Message = match.Message,
                Type = issueType,
            };

            // Line
            if (!string.IsNullOrEmpty(match.Line))
            {
                if (int.TryParse(match.Line, NumberStyles.None, CultureInfo.InvariantCulture, out var line))
                {
                    issue.Data["line"] = line.ToString(CultureInfo.InvariantCulture);
                }
                else
                {
                    _executionContext.Debug($"Unable to parse line number '{match.Line}'");
                }
            }

            // Column
            if (!string.IsNullOrEmpty(match.Column))
            {
                if (int.TryParse(match.Column, NumberStyles.None, CultureInfo.InvariantCulture, out var column))
                {
                    issue.Data["col"] = column.ToString(CultureInfo.InvariantCulture);
                }
                else
                {
                    _executionContext.Debug($"Unable to parse column number '{match.Column}'");
                }
            }

            // Code
            if (!string.IsNullOrWhiteSpace(match.Code))
            {
                issue.Data["code"] = match.Code.Trim();
            }

            // File
            try
            {
                if (!string.IsNullOrWhiteSpace(match.File))
                {
                    var file = match.File;
                    var translate = _container != null;

                    // Root using fromPath
                    if (!string.IsNullOrWhiteSpace(match.FromPath) && !Path.IsPathFullyQualified(file))
                    {
                        var fromDirectory = Path.GetDirectoryName(match.FromPath);
                        if (!string.IsNullOrWhiteSpace(fromDirectory))
                        {
                            file = Path.Combine(fromDirectory, file);
                        }
                    }

                    // Root using workspace
                    if (!Path.IsPathFullyQualified(file))
                    {
                        var workspace = _executionContext.GetGitHubContext("workspace");
                        ArgUtil.NotNullOrEmpty(workspace, "workspace");

                        file = Path.Combine(workspace, file);
                        translate = false;
                    }

                    // Remove relative pathing and normalize slashes
                    file = Path.GetFullPath(file);

                    // Translate to host
                    if (translate)
                    {
                        file = _container.TranslateToHostPath(file);
                        file = Path.GetFullPath(file);
                    }

                    // Check whether the file exists
                    if (File.Exists(file))
                    {
                        // Check whether the file is under the workflow repository
                        var repositoryPath = GetRepositoryPath(file);
                        if (!string.IsNullOrEmpty(repositoryPath))
                        {
                            // Get the relative file path
                            var relativePath = file.Substring(repositoryPath.Length).TrimStart(Path.DirectorySeparatorChar);

                            // Prefer `/` on all platforms
                            issue.Data["file"] = relativePath.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
                        }
                        else
                        {
                            _executionContext.Debug($"Dropping file value '{file}'. Path is not under the workflow repo.");
                        }
                    }
                    else
                    {
                        _executionContext.Debug($"Dropping file value '{file}'. Path does not exist");
                    }
                }
            }
            catch (Exception ex)
            {
                _executionContext.Debug($"Dropping file value '{match.File}' and fromPath value '{match.FromPath}'. Exception during validation: {ex.ToString()}");
            }

            return issue;
        }

        private string GetRepositoryPath(string filePath, int recursion = 0)
        {
            // Prevent the cache from growing too much
            if (_directoryMap.Count > 100)
            {
                _directoryMap.Clear();
            }

            // Empty directory means we hit the root of the drive
            var directoryPath = Path.GetDirectoryName(filePath);
            if (string.IsNullOrEmpty(directoryPath) || recursion > _failsafe)
            {
                return null;
            }

            // Check the cache
            if (_directoryMap.TryGetValue(directoryPath, out string repositoryPath))
            {
                return repositoryPath;
            }

            try
            {
                // Check if .git/config exists
                var gitConfigPath = Path.Combine(directoryPath, ".git", "config");
                if (File.Exists(gitConfigPath))
                {
                    // Check if the config contains the workflow repository url
                    var serverUrl = _executionContext.GetGitHubContext("server_url");
                    serverUrl = !string.IsNullOrEmpty(serverUrl) ? serverUrl : "https://github.com";
                    var host = new Uri(serverUrl, UriKind.Absolute).Host;
                    var nameWithOwner = _executionContext.GetGitHubContext("repository");
                    var patterns = new[] {
                        $"url = {serverUrl}/{nameWithOwner}",
                        $"url = git@{host}:{nameWithOwner}.git",
                    };
                    var content = File.ReadAllText(gitConfigPath);
                    foreach (var line in content.Split("\n").Select(x => x.Trim()))
                    {
                        foreach (var pattern in patterns)
                        {
                            if (String.Equals(line, pattern, StringComparison.OrdinalIgnoreCase))
                            {
                                repositoryPath = directoryPath;
                                break;
                            }
                        }
                    }
                }
                else
                {
                    // Recursive call
                    repositoryPath = GetRepositoryPath(directoryPath, recursion + 1);
                }
            }
            catch (Exception ex)
            {
                _executionContext.Debug($"Error when attempting to determine whether the path '{filePath}' is under the workflow repository: {ex.Message}");
            }

            _directoryMap[directoryPath] = repositoryPath;
            return repositoryPath;
        }
    }
}