// 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);
}
}
}
}
}