File: CauseIssueRenderer.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;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using static AnalyzeCiFailure.CiFailureJson;
 
namespace AnalyzeCiFailure;
 
/// <summary>Renders recurring-cause issues while preserving operator text and managed occurrence sections.</summary>
internal static partial class CauseIssueRenderer
{
    private const string TestsStart = "<!-- ci-failure-tests:start -->";
    private const string TestsEnd = "<!-- ci-failure-tests:end -->";
    private const string OccurrencesStart = "<!-- ci-failure-occurrences:start -->";
    private const string OccurrencesEnd = "<!-- ci-failure-occurrences:end -->";
    private const string OccurrencesHeader = "| Date | Build | Job | Context |\n|------|-------|-----|----|";
 
    /// <summary>Carries a rendered cause issue body, title and labels, including an unavailable body when rendering is unsafe.</summary>
    internal sealed record IssueDraft(string? Body, string Title, string Labels);
 
    internal static string JobNames(IEnumerable<string> names, string format)
    {
        var safe = names.Select(name => DiagnosticTextSanitizer.Sanitize(name, limit: 500)).ToArray();
        return format == "plain" ? string.Join(", ", safe) :
            string.Join("<br>", safe.Select(name => MarkdownFormatting.CodeSpan(format == "table" ? name.Replace("|", "\\|", StringComparison.Ordinal) : name)));
    }
 
    internal static IssueDraft RenderCanonicalIssue(
        JsonObject cause,
        RunContext? context,
        PublicationEvidence evidence,
        string runUrl,
        string scope,
        string pr,
        string jobs,
        string row)
    {
        cause = (JsonObject)DiagnosticTextSanitizer.SanitizeCause(cause)!;
        var id = Text(cause["id"])!;
        var type = Text(cause["type"])!;
        var main = type == "main-repository-breakage";
        var title = DiagnosticTextSanitizer.Sanitize(Text(cause["title"]) ?? "", limit: 238);
        var test = DiagnosticTextSanitizer.Sanitize(Text(cause["test_name"]) ?? "", limit: 500);
        if (string.IsNullOrWhiteSpace(title))
        {
            title = id;
        }
        if (main)
        {
            title = "Main branch CI failure at " + (AsString(context?.HeadSha) ?? "unknown");
        }
        var total = cause.ContainsKey("occurrences") ? (cause["occurrences"] as JsonArray)?.Count ?? 0 : 1;
        if (!(total > 0))
        {
            throw new ValidationException("invalid stored occurrence history");
        }
        var buildContext = new StringBuilder();
        if (main)
        {
            buildContext.Append("Affected branch: `main`\n")
                .Append("Last successful main SHA: `").Append(AsString(evidence.LastSuccess?.HeadSha) ?? "unknown").Append("`\n")
                .Append("Failed main SHA: `").Append(AsString(context?.HeadSha) ?? "unknown").Append("`\n");
            if (evidence.CandidateState == CandidateHistoryState.Available && AsNormalizedNumber(evidence.TriggeringMerge?.Number) is not null)
            {
                buildContext.Append("Triggering merge PR (context only, not necessarily causal): #")
                    .Append(evidence.TriggeringMerge!.Number!.Value.GetRawText()).Append(' ')
                    .Append(MarkdownFormatting.CodeSpan(DiagnosticTextSanitizer.Sanitize(evidence.TriggeringMerge.Title ?? "", limit: 238))).Append('\n');
            }
        }
        else
        {
            buildContext.Append(test.Length == 0 ? $"Build error leg: {jobs}\n" : $"Build error leg or test failing: {jobs} / {MarkdownFormatting.CodeSpan(test)}\n");
        }
        if (scope == "pull-request" && pr != "0")
        {
            buildContext.Append("Pull request: #").Append(pr).Append('\n');
        }
        var error = Text(cause["error_pattern"]) ?? "";
        if (string.IsNullOrWhiteSpace(error))
        {
            error = "No diagnostic pattern recorded.";
        }
        error = main
            ? "The main branch CI run failed. See the linked workflow run and trusted commit context above for diagnostics."
            : DiagnosticTextSanitizer.Sanitize(error, limit: 500, multiline: true);
        var body = MarkdownTemplate.Render(
            "CauseIssue",
            ("CAUSE_ID", id),
            ("CAUSE_TYPE", type),
            ("RUN_URL", runUrl),
            ("BUILD_CONTEXT", buildContext.ToString()),
            ("ERROR", MarkdownFormatting.Indent(error)),
            ("DESCRIPTION", MarkdownFormatting.CodeSpan(title)),
            ("OCCURRENCES", OccurrenceSection([row], total).TrimEnd('\n')));
        var issueTitle = (main ? "[Main CI Failure] " : "[CI Failure] ") + title;
        if (!(issueTitle.EnumerateRunes().Count() <= 256))
        {
            throw new ValidationException("IssueDraft title exceeds GitHub's 256-character limit");
        }
        var labels = "ci-failure-cause" + (main ? ",main-ci-break" : type == "flaky-test" ? ",test-failure" : "");
 
        return new IssueDraft(body, issueTitle, labels);
    }
 
    internal static string AppendOccurrence(string body, string row, int total)
    {
        if (!IsOccurrence(row))
        {
            throw new ValidationException("invalid occurrence row");
        }
        var (prefix, section, suffix, legacy) = OccurrenceParts(body);
        var rows = OccurrenceRows(section, legacy).Append(row).ToArray();
        if (!(total >= rows.Length))
        {
            throw new ValidationException("occurrence total is smaller than the rendered history");
        }
 
        return prefix.TrimEnd('\r', '\n') + (prefix.EndsWith("\r\n", StringComparison.Ordinal) ? "\r\n\r\n" : "\n\n")
            + OccurrenceSection(rows, total) + suffix;
    }
 
    internal static bool HasOccurrence(string body, string runId)
    {
        var parts = OccurrenceParts(body);
 
        return OccurrenceRows(parts.Section, parts.Legacy).Any(row => OccurrenceRun(row) == runId);
    }
 
    internal static string MigrateMainBody(string current, string canonical)
    {
        var oldParts = OccurrenceParts(current);
        var newParts = OccurrenceParts(canonical);
        if (!(oldParts.Prefix.Split('\n')[0].TrimEnd('\r') == newParts.Prefix.Split('\n')[0].TrimEnd('\r')))
        {
            throw new ValidationException("issue identity marker does not match");
        }
        static (string Generated, string Notes) Prefix(string prefix)
        {
            var types = Markers(prefix, "**Type**: main-repository-breakage");
            if (!(types.Count == 1))
            {
                throw new ValidationException("ambiguous main issue type");
            }
            var end = types[0].Index + types[0].Length;
 
            return (prefix[..end], prefix[end..].Trim('\r', '\n'));
        }
        var oldPrefix = Prefix(oldParts.Prefix);
        var newPrefix = Prefix(newParts.Prefix);
        var occurrences = oldParts.Legacy ? oldParts.Section : OccurrencesStart + oldParts.Section + OccurrencesEnd + "\n";
 
        return newPrefix.Generated + (oldPrefix.Notes.Length > 0 ? "\n\n" + oldPrefix.Notes : "") + "\n\n" + occurrences + oldParts.Suffix;
    }
 
    internal static string? FitIssue(string body, JsonObject[] observations, int maxBytes = ValidationRules.MaximumCommentBytes)
    {
        // All issue-body byte fitting happens here. Only publisher-owned tests and
        // earlier occurrence rows can be omitted; operator notes and the latest row stay.
        var rows = observations.Select(t =>
        {
            var row = $"- {MarkdownFormatting.CodeSpan(DiagnosticTextSanitizer.Sanitize(Text(t["name"])!, limit: 500))} — job {GetValueText(t["job_id"])} ({MarkdownFormatting.CodeSpan(DiagnosticTextSanitizer.Sanitize(Text(t["job"])!, limit: 500))})";
            if (Text(t["observation_id"]) is { } observation)
            {
                row += $" — {MarkdownFormatting.CodeSpan(DiagnosticTextSanitizer.Sanitize(observation, limit: 100))}, result {MarkdownFormatting.CodeSpan(DiagnosticTextSanitizer.Sanitize(Text(t["result_file"]) ?? "", limit: 500))}";
                if (Text(t["execution_id"]) is { } execution)
                {
                    row += $", execution {MarkdownFormatting.CodeSpan(DiagnosticTextSanitizer.Sanitize(execution, limit: 500))}";
                }
                foreach (var field in new[] { "error", "stack_trace", "standard_output", "standard_error" })
                {
                    if (Text(t[field]) is { Length: > 0 } diagnostic)
                    {
                        // Indented code cannot interrupt the label's paragraph.
                        // https://spec.commonmark.org/0.31.2/#indented-code-blocks
                        row += $"\n\n    {field}:\n\n" + MarkdownFormatting.Indent(DiagnosticTextSanitizer.Sanitize(diagnostic, limit: 4000, multiline: true), spaces: 8);
                    }
                }
            }
            return row;
        }).ToArray();
        var projected = ProjectTests(body, rows, omitted: 0);
        if (projected is null || Encoding.UTF8.GetByteCount(projected) <= maxBytes)
        {
            return projected;
        }
 
        (string Prefix, string Section, string Suffix, bool Legacy) parts;
        string[] occurrences;
        try
        {
            parts = OccurrenceParts(body);
            // Replay can skip appending a row. Validate the entire section before
            // any fitting, so unrecognized operator content is never discarded.
            occurrences = OccurrenceRows(parts.Section, parts.Legacy);
        }
        catch (ValidationException)
        {
            return null;
        }
 
        // Redact/render each historical observation only once. Suffix byte sums
        // select the retained rows without repeatedly rebuilding large sections.
        var rowBytes = SuffixBytes(rows);
        var allOmitted = ProjectTests(body, rows, rows.Length)!;
        var baseBytes = Encoding.UTF8.GetByteCount(allOmitted) - Encoding.UTF8.GetByteCount(OmittedNotice(rows.Length));
        for (var omitted = 1; omitted <= rows.Length; omitted++)
        {
            var size = baseBytes + rowBytes[omitted] + Math.Max(0, rows.Length - omitted - 1) +
                Encoding.UTF8.GetByteCount(OmittedNotice(omitted));
            if (size <= maxBytes)
            {
                return ProjectTests(body, rows, omitted);
            }
        }
 
        var count = OccurrenceSummaryCountRegex().Match(parts.Section);
        if (occurrences.Length <= 1 || !count.Success ||
            !int.TryParse(count.Groups["total"].Value, out var total))
        {
            return null;
        }
        var prefix = parts.Prefix.TrimEnd('\r', '\n') + (parts.Prefix.EndsWith("\r\n", StringComparison.Ordinal) ? "\r\n\r\n" : "\n\n");
        var emptySection = prefix + OccurrenceSection([], total) + parts.Suffix;
        var emptyBytes = Encoding.UTF8.GetByteCount(ProjectTests(emptySection, rows, rows.Length)!);
        var occurrenceBytes = SuffixBytes(occurrences);
        for (var removed = 1; removed < occurrences.Length; removed++)
        {
            var remaining = occurrences.Length - removed;
            var size = emptyBytes + occurrenceBytes[removed] + remaining - 1 +
                remaining.ToString(System.Globalization.CultureInfo.InvariantCulture).Length - 1;
            if (size <= maxBytes)
            {
                return ProjectTests(prefix + OccurrenceSection(occurrences[removed..], total) + parts.Suffix, rows, rows.Length);
            }
        }
 
        return null;
    }
 
    private static long[] SuffixBytes(string[] rows)
    {
        var sizes = new long[rows.Length + 1];
        for (var i = rows.Length - 1; i >= 0; i--)
        {
            sizes[i] = sizes[i + 1] + Encoding.UTF8.GetByteCount(rows[i]);
        }
 
        return sizes;
    }
 
    private static string[] OccurrenceRows(string section, bool legacy)
    {
        var lines = section.Split('\n');
        if (!lines.All(line => line.Length == 0 || line is "## Occurrences"
            or "| Date | Build | Job | Context |" or "|------|-------|-----|----|" ||
            legacy && line == "| Date | Build | Job | PR |" ||
            OccurrenceSummaryRegex().IsMatch(line) || IsOccurrence(line)))
        {
            throw new ValidationException("unsupported occurrence section contents");
        }
 
        return lines.Where(IsOccurrence).ToArray();
    }
 
    private static bool IsOccurrence(string row) => OccurrenceRun(row).Length > 0;
 
    private static string OccurrenceRun(string row)
        // Managed rows have the shape:
        // | 2026-10-21 | [123](https://github.com/owner/repo/actions/runs/123) | ` Job ` | #42 |
        // Only the build column identifies a run; links in job text or operator notes do not.
        => OccurrenceRowRegex().Match(row)
            .Groups["run"].Value;
 
    private static string OccurrenceSection(string[] rows, int total)
        => $"{OccurrencesStart}\n## Occurrences\n\nShowing {rows.Length} most recent of {total} occurrences.\n\n{OccurrencesHeader}\n{string.Join('\n', rows)}\n{OccurrencesEnd}\n";
 
    private static (string Prefix, string Section, string Suffix, bool Legacy) OccurrenceParts(string body)
    {
        var start = Markers(body, OccurrencesStart);
        var end = Markers(body, OccurrencesEnd);
        if (start.Count > 0 || end.Count > 0)
        {
            if (!(start.Count == 1 && end.Count == 1 &&
                end[0].Index >= start[0].Index + start[0].Length))
            {
                throw new ValidationException("ambiguous managed occurrence section");
            }
            var after = end[0].Index + end[0].Length;
            // Own the closing delimiter's line ending, but retain all later operator
            // text verbatim, including CRLF and blank lines, outside the managed region.
            if (body.AsSpan(after).StartsWith("\r\n"))
            {
                after += 2;
            }
            else if (body.AsSpan(after).StartsWith("\n"))
            {
                after++;
            }
 
            return (body[..start[0].Index], body[(start[0].Index + start[0].Length)..end[0].Index]
                .Replace("\r\n", "\n", StringComparison.Ordinal), body[after..], false);
        }
        body = body.Replace("\r\n", "\n", StringComparison.Ordinal);
        var legacy = body.Split("\n## Occurrences\n", StringSplitOptions.None);
        if (!(legacy.Length == 2))
        {
            throw new ValidationException("unsupported legacy occurrence section");
        }
 
        return (legacy[0], "## Occurrences\n" + legacy[1], "", true);
    }
 
    internal static JsonObject[] Observations(JsonObject history)
        => ((JsonArray)history["occurrences"]!)
            .SelectMany(o => (Field(o, "tests") as JsonArray ?? []).OfType<JsonObject>()
                .Select(test => (Test: test, Run: GetValueText(Field(o, "run_id")))))
            .DistinctBy(item => (Text(item.Test["name"]), ReadNormalizedNumber(item.Test["job_id"]),
                Text(item.Test["observation_id"]), Text(item.Test["observation_id"]) is null ? null : item.Run))
            .Select(item => item.Test).ToArray();
 
    private static string? ProjectTests(string body, string[] rows, int omitted)
    {
        if (rows.Length == 0)
        {
            return body;
        }
 
        var start = Markers(body, TestsStart);
        var end = Markers(body, TestsEnd);
        string prefix;
        string suffix;
        if (start.Count == 0 && end.Count == 0)
        {
            // This section is publisher-owned; all existing operator text stays intact.
            var occurrences = Markers(body, OccurrencesStart);
            if (occurrences.Count > 1)
            {
                return null;
            }
 
            prefix = occurrences.Count == 1 ? body[..occurrences[0].Index] : body.TrimEnd() + "\n\n";
            suffix = occurrences.Count == 1 ? "\n\n" + body[occurrences[0].Index..] : "\n";
        }
        else if (start.Count != 1 || end.Count != 1 || end[0].Index < start[0].Index + start[0].Length)
        {
            return null;
        }
        else
        {
            prefix = body[..start[0].Index];
            suffix = body[(end[0].Index + end[0].Length)..];
        }
 
        const string header = TestsStart + "\n## Observed tests\n\n";
        const string footer = "\n" + TestsEnd;
        // Only the managed presentation is bounded. Preserve durable observations,
        // operator text, and the separately managed latest occurrence.
        var section = header + string.Join("\n", rows.Skip(omitted)) + OmittedNotice(omitted) + footer;
 
        return prefix + section + suffix;
    }
 
    private static string OmittedNotice(int omitted)
        => omitted == 0 ? "" : FormattableString.Invariant($"\n\n{omitted} earlier observations omitted; complete records remain in cause history.");
 
    private static MatchCollection Markers(string body, string marker)
        // Exact names can include "<!-- ci-failure-tests:end -->" inside code spans.
        // Only a whole line owns a section; leave CRLF bytes outside the marker.
        => Regex.Matches(body, "^" + Regex.Escape(marker) + @"(?=\r?$)", RegexOptions.Multiline | RegexOptions.CultureInvariant);
 
    [GeneratedRegex(@"Showing [0-9]+ most recent of (?<total>[0-9]+) occurrences\.")]
    private static partial Regex OccurrenceSummaryCountRegex();
 
    [GeneratedRegex(@"^Showing [0-9]+ most recent of [0-9]+ occurrences\.\z")]
    private static partial Regex OccurrenceSummaryRegex();
 
    [GeneratedRegex(@"^\| [0-9]{4}-[0-9]{2}-[0-9]{2} \| \[(?<run>[0-9]+)\]\(https://github\.com/[^\n]+\) \| .* \| (main|unavailable|#[0-9]+) \|\z")]
    private static partial Regex OccurrenceRowRegex();
 
}