File: RunSummaryBuilder.cs
Web Access
Project: src\tools\AnalyzeCiFailure\AnalyzeCiFailure.csproj (AnalyzeCiFailure)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using static AnalyzeCiFailure.CiFailureJson;
 
namespace AnalyzeCiFailure;
 
/// <summary>Builds durable per-run history records from validated decisions and captured run facts.</summary>
internal static class RunSummaryBuilder
{
    internal static RunSummaryRecord Create(
        AnalysisReport analysis,
        RunEvidence evidence,
        PublicationEvidence publication,
        string observedAt)
    {
        var context = evidence.Context;
        var main = context.RunScope == RunScope.Main;
        var classifications = analysis.Jobs.GroupBy(j => AsNormalizedNumber(j.Id))
            .ToDictionary(g => g.Key!.Value, g => g.Last());
        var jobs = evidence.Jobs.Select(job => job with { Name = DiagnosticTextSanitizer.Sanitize(job.Name!, limit: 500) }).ToArray();
        var names = jobs.Select(job => job.Name).ToHashSet(StringComparer.Ordinal);
        var pr = publication.PullRequest;
        var triggering = publication.TriggeringMerge;
 
        return new RunSummaryRecord
        {
            RunId = context.RunId,
            RunAttempt = context.RunAttempt,
            RunUrl = DisplayText(evidence.Metadata.HtmlUrl, limit: 1000),
            RunScope = context.RunScope,
            AnalyzedAt = observedAt,
            Verdict = analysis.Verdict,
            PullRequest = !main && context.RunScope == RunScope.PullRequest && AsNormalizedNumber(pr?.Number) is not null
                ? new PullRequestSummary
                {
                    Number = pr!.Number,
                    Title = DisplayText(pr.Title, limit: 500),
                    Author = DisplayText(pr.User, limit: 100),
                    State = DisplayText(pr.State, limit: 50),
                    HeadBranch = DisplayText(pr.HeadBranch, limit: 500),
                    BaseBranch = DisplayText(pr.BaseBranch, limit: 500),
                    Url = DisplayText(pr.HtmlUrl, limit: 1000),
                } : null,
            TriggeringMerge = main && publication.CandidateState == CandidateHistoryState.Available && AsNormalizedNumber(triggering?.Number) is not null
                ? new TriggeringMergeSummary
                {
                    Number = triggering!.Number,
                    Title = DisplayText(triggering.Title, limit: 500),
                    Author = DisplayText(triggering.User?.Login, limit: 100),
                    State = DisplayText(triggering.State, limit: 50),
                    HeadBranch = DisplayText(triggering.Head?.Ref, limit: 500),
                    BaseBranch = DisplayText(triggering.Base?.Ref, limit: 500),
                    Url = DisplayText(triggering.HtmlUrl, limit: 1000),
                    MergedAt = triggering.MergedAt,
                } : null,
            MainContext = main ? new MainSummary
            {
                LastSuccessfulSha = publication.LastSuccess?.HeadSha,
                FailedSha = context.HeadSha,
                CandidateState = publication.CandidateState,
                Candidates = publication.Candidates?.Select(candidate => candidate with
                {
                    Message = DisplayText(candidate.Message, limit: 500),
                    HtmlUrl = DisplayText(candidate.HtmlUrl, limit: 1000),
                    PullRequest = new CandidatePullRequest
                    {
                        Number = candidate.PullRequest?.Number,
                        Title = DisplayText(candidate.PullRequest?.Title, limit: 500),
                        Url = DisplayText(candidate.PullRequest?.Url, limit: 1000),
                        MergedAt = candidate.PullRequest?.MergedAt,
                    },
                }).ToArray(),
            } : null,
            Jobs = jobs.Select(job =>
            {
                classifications.TryGetValue(AsNormalizedNumber(job.Id)!.Value, out var classification);
 
                return new SummaryJob
                {
                    Name = job.Name,
                    Id = job.Id,
                    Conclusion = job.Conclusion,
                    Url = DisplayText(job.HtmlUrl, limit: 1000),
                    Classification = classification?.Classification,
                    Reason = classification?.Reason ?? "",
                    FailedSteps = Steps(job.Steps)
                        .Where(step => step.Conclusion is "failure" or "cancelled" or "timed_out")
                        .Select(step => DisplayText(step.Name, limit: 500)).ToArray(),
                };
            }).ToArray(),
            Tests = analysis.Tests.Select(test => new SummaryTest
            {
                Name = DiagnosticTextSanitizer.Sanitize(test.DisplayName ?? test.Name ?? "", limit: 500),
                Job = names.Contains(test.Job) ? test.Job : "",
                Error = test.Error ?? "",
                StackTrace = test.StackTrace ?? "",
                StandardOutput = test.StandardOutput ?? "",
                StandardError = test.StandardError ?? "",
                Classification = test.Classification is { } classification ? EnumText(classification) : "",
                Reason = test.Reason ?? "",
                JobId = AsNormalizedNumber(test.JobId) is not null ? test.JobId : null,
                ObservationId = test.ObservationId,
                ExecutionId = test.ExecutionId,
                ResultFile = test.ResultFile,
                ResultFormat = test.ResultFormat,
            }).ToArray(),
            Causes = analysis.Causes.ToArray(),
        };
    }
 
    private static IEnumerable<WorkflowStep> Steps(JsonElement? value)
        // GitHub can omit steps for interrupted jobs. Non-array diagnostics and
        // non-object entries were never treated as failed steps.
        => value is { ValueKind: JsonValueKind.Array } array
            ? array.EnumerateArray().Where(step => step.ValueKind == JsonValueKind.Object)
                .Select(step => CiFailureJson.ToContract<WorkflowStep>(step)!)
            : [];
 
    private static string DisplayText(string? value, int limit)
        => DiagnosticTextSanitizer.SanitizeRunMetadata(value ?? "", limit);
}