// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using EpicGames.Core; using EpicGames.Horde.Jobs.Templates; using EpicGames.Horde.Streams; using Microsoft.Extensions.Logging; namespace EpicGames.Horde.Issues { /// /// Type of a key in an issue, used for grouping. /// public enum IssueKeyType { /// /// Unknown type /// None, /// /// Filename /// File, /// /// Secondary file /// Note, /// /// Name of a symbol /// Symbol, /// /// Hash of a particular error /// Hash, /// /// Identifier for a particular step /// Step, } /// /// Defines a key which can be used to group an issue with other issues /// public class IssueKey : IEquatable { /// /// Name of the key /// public string Name { get; } /// /// Type of the key /// [JsonConverter(typeof(JsonStringEnumConverter))] public IssueKeyType Type { get; } /// /// Arbitrary string that can be used to discriminate between otherwise identical keys, limiting the issues that it can merge with. /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Scope { get; } /// /// Constructor /// public IssueKey(string name, IssueKeyType type) { Name = name; Type = type; } /// /// Constructor /// [JsonConstructor] public IssueKey(string name, IssueKeyType type, string? scope = null) { Name = name; Type = type; Scope = scope; } /// /// Creates an issue key for a file /// public static IssueKey FromFile(string file, bool note = false) => new IssueKey(file, note? IssueKeyType.Note : IssueKeyType.File); /// /// Creates an issue key for a file /// public static IssueKey FromSymbol(string name) => new IssueKey(name, IssueKeyType.Symbol); /// /// Creates an issue key for a particular hash /// public static IssueKey FromHash(Md5Hash hash) => new IssueKey(hash.ToString(), IssueKeyType.Hash); /// /// Creates an issue key for a particular step /// public static IssueKey FromStep(StreamId streamId, TemplateId templateId, string nodeName) => new IssueKey($"{streamId}:{templateId}:{nodeName}", IssueKeyType.Step); /// /// Creates an issue key for a particular step and severity /// public static IssueKey FromStepAndSeverity(StreamId streamId, TemplateId templateId, string nodeName, LogLevel severity) => new IssueKey($"{streamId}:{templateId}:{nodeName}:{severity}", IssueKeyType.Step); /// public bool Equals(IssueKey? other) => other is not null && other.Name.Equals(Name, StringComparison.OrdinalIgnoreCase) && other.Type == Type && String.Equals(Scope, other.Scope, StringComparison.Ordinal); /// public override bool Equals(object? obj) => obj is IssueKey other && Equals(other); /// public override int GetHashCode() => HashCode.Combine(String.GetHashCode(Name, StringComparison.OrdinalIgnoreCase), Type, Scope?.GetHashCode(StringComparison.Ordinal) ?? 0); /// public override string ToString() => Name; /// public static bool operator ==(IssueKey left, IssueKey right) => left.Equals(right); /// public static bool operator !=(IssueKey left, IssueKey right) => !left.Equals(right); } /// /// Extension methods for issue keys /// public static class IssueKeyExtensions { /// /// Adds a new entry to a set /// public static void Add(this HashSet entries, string name, IssueKeyType type, string? scope = null) { entries.Add(new IssueKey(name, type, scope)); } /// /// Adds all the assets from the given log event /// /// Set of keys /// The log event to parse public static void AddAssets(this HashSet keys, IssueEvent issueEvent) { foreach (JsonLogEvent line in issueEvent.Lines) { JsonDocument document = JsonDocument.Parse(line.Data); string? relativePath; if (document.RootElement.TryGetNestedProperty("properties.asset.relativePath", out relativePath) || document.RootElement.TryGetNestedProperty("properties.asset.$text", out relativePath)) { int endIdx = relativePath.LastIndexOfAny(new char[] { '/', '\\' }) + 1; string fileName = relativePath.Substring(endIdx); IssueKey issueKey = new IssueKey(fileName, IssueKeyType.File); keys.Add(issueKey); } } } /// /// Extracts a list of source files from an event /// /// Set of keys /// The event data public static void AddDepotPaths(this HashSet keys, IssueEvent issueEvent) { foreach (JsonProperty property in issueEvent.Lines.SelectMany(x => x.FindPropertiesOfType(LogValueType.DepotPath))) { JsonElement value; if (property.Value.TryGetProperty(LogEventPropertyName.Text.Span, out value) && value.ValueKind == JsonValueKind.String) { string path = value.GetString() ?? String.Empty; string fileName = path.Substring(path.LastIndexOf('/') + 1); keys.Add(new IssueKey(fileName, IssueKeyType.File)); } } } /// /// Extracts a list of source files from an event /// /// Set of keys /// The event hash /// Scope for merging this hash value public static void AddHash(this HashSet keys, Md5Hash hash, string? scope = null) { keys.Add(hash.ToString(), IssueKeyType.Hash, scope); } /// /// Extracts a list of source files from an event /// /// Set of keys /// The event data /// The scope of the key to add public static void AddSourceFiles(this HashSet keys, IssueEvent issueEvent, string? scope = null) { foreach (JsonLogEvent line in issueEvent.Lines) { JsonDocument document = JsonDocument.Parse(line.Data); JsonElement properties; if (document.RootElement.TryGetProperty("properties", out properties) && properties.ValueKind == JsonValueKind.Object) { IssueKeyType type = IssueKeyType.File; if (properties.TryGetProperty("note", out JsonElement noteElement) && noteElement.GetBoolean()) { type = IssueKeyType.Note; } foreach (JsonProperty property in properties.EnumerateObject()) { if (property.NameEquals("file") && property.Value.ValueKind == JsonValueKind.String) { keys.AddSourceFile(property.Value.GetString()!, type, scope); } if (property.Value.HasStringProperty("$type", "SourceFile") && property.Value.TryGetStringProperty("relativePath", out string? value)) { keys.AddSourceFile(value, type, scope); } } } } } /// /// Add a new source file to a list of unique source files /// /// List of source files /// File to add /// Type of key to add /// The scope of the key to add public static void AddSourceFile(this HashSet keys, string relativePath, IssueKeyType type, string? scope = null) { int endIdx = relativePath.LastIndexOfAny(new char[] { '/', '\\' }) + 1; string fileName = relativePath.Substring(endIdx); IssueKey key = new IssueKey(fileName, type, scope); keys.Add(key); } /// /// Parses symbol names from a log event /// /// List of source files /// The log event data public static void AddSymbols(this HashSet keys, IssueEvent eventData) { foreach (JsonLogEvent line in eventData.Lines) { JsonDocument document = JsonDocument.Parse(line.Data); string? identifier; if (document.RootElement.TryGetNestedProperty("properties.symbol.identifier", out identifier)) { IssueKey key = new IssueKey(identifier, IssueKeyType.Symbol); keys.Add(key); } } } } }