File: AnalysisPublisher.Overflow.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.Globalization;
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using static AnalyzeCiFailure.CiFailureJson;
 
namespace AnalyzeCiFailure;
 
/// <summary>Preflights and publishes one run-level destination for many independent causes.</summary>
internal sealed partial class AnalysisPublisher
{
    private async Task<OverflowPublication> PrepareOverflowAsync(
        ValidatedAnalysis analysis,
        List<PreparedCause> causes,
        JsonObject? storedRun)
    {
        var runId = analysis.Bundle.Run.Id.ToString(CultureInfo.InvariantCulture);
        var marker = $"<!-- ci-failure-overflow:{runId} -->";
        var inventory = await LoadIssueInventoryAsync();
        var matches = inventory.OfType<JsonObject>().Where(issue =>
            OverflowIssueRenderer.HasMarker(Text(issue["body"]), marker))
            .DistinctBy(issue => GitHubJson.ReadRequiredId(issue["number"])).ToArray();
        if (matches.Length > 1)
        {
            throw new ValidationException("Multiple aggregate issues identify this source run.");
        }
        JsonObject? existing = null;
        var linked = Text(storedRun?["overflow_issue_url"]);
        if (linked is not null || matches.Length == 1)
        {
            var number = linked is null ? GitHubJson.ReadRequiredId(matches[0]["number"]).ToString(CultureInfo.InvariantCulture) :
                linked[(linked.LastIndexOf('/') + 1)..];
            if (linked is not null && (linked != $"https://github.com/{_repository}/issues/{number}" ||
                !long.TryParse(number, NumberStyles.None, CultureInfo.InvariantCulture, out var linkedId) ||
                linkedId <= 0 || linkedId.ToString(CultureInfo.InvariantCulture) != number ||
                matches.Any(issue => GetValueText(issue["number"]) != number)))
            {
                throw new ValidationException("Stored aggregate issue link conflicts with the source run.");
            }
            existing = await _github.GetAsync($"repos/{_repository}/issues/{number}") as JsonObject;
            if (existing is null || GetValueText(existing["number"]) != number ||
                Text(existing["html_url"]) != $"https://github.com/{_repository}/issues/{number}" ||
                Text(Field(existing["user"], "login")) != "github-actions[bot]" ||
                !IsFalse(existing["locked"]) || Text(existing["state"]) is not ("open" or "closed") ||
                Text(existing["state_reason"]) == "duplicate" || existing["pull_request"] is not null ||
                !OverflowIssueRenderer.HasMarker(Text(existing["body"]), marker))
            {
                throw new ValidationException("Aggregate issue destination is not a uniquely identified, unlocked automation issue.");
            }
        }
 
        var pages = OverflowIssueRenderer.Pages(Rows(), analysis.Run.Url);
        var body = OverflowIssueRenderer.Merge(Text(existing?["body"]), marker, pages[0]);
        var comments = existing is null ? [] :
            await _github.GetRowsAsync($"repos/{_repository}/issues/{GetValueText(existing["number"])}/comments?per_page=100");
        var owned = new Dictionary<int, JsonObject>();
        var ownedIds = new HashSet<long>();
        var prefix = $"<!-- ci-failure-overflow:{runId}:page:";
        foreach (var comment in comments.OfType<JsonObject>().Where(comment =>
            Text(Field(comment["user"], "login")) == "github-actions[bot]"))
        {
            var text = Text(comment["body"]) ?? "";
            if (!text.StartsWith(prefix, StringComparison.Ordinal))
            {
                continue;
            }
            var end = text.IndexOf(" -->", prefix.Length, StringComparison.Ordinal);
            if (end < 0 || !int.TryParse(text.AsSpan(prefix.Length, end - prefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out var page) ||
                page < 2 || page.ToString(CultureInfo.InvariantCulture) != text[prefix.Length..end] ||
                !OverflowIssueRenderer.HasMarker(text, text[..(end + 4)]) ||
                !GitHubJson.TryReadId(comment["id"], out var id) || !ownedIds.Add(id) || !owned.TryAdd(page, comment))
            {
                throw new ValidationException("Aggregate continuation ownership or page identity is ambiguous.");
            }
        }
        var continuations = new List<OverflowContinuation>();
        foreach (var page in Enumerable.Range(start: 2, Math.Max(0, pages.Count - 1)).Concat(owned.Keys).Distinct().Order())
        {
            var content = page <= pages.Count ? pages[page - 1] :
                "[automated] This continuation is superseded by the updated aggregate report.";
            owned.TryGetValue(page, out var previous);
            var pageMarker = prefix + page.ToString(CultureInfo.InvariantCulture) + " -->";
            continuations.Add(new(
                previous is null ? null : GetValueText(previous["id"]),
                OverflowIssueRenderer.Merge(Text(previous?["body"]), pageMarker, content)));
        }
        return new(existing, body, continuations,
            $"[CI Failure] {causes.Count.ToString(CultureInfo.InvariantCulture)} independent causes in run {runId}");
 
        IEnumerable<string> Rows()
        {
            foreach (var item in causes)
            {
                var id = MarkdownFormatting.CodeSpan(item.Cause.Id);
                var title = MarkdownFormatting.CodeSpan(DiagnosticTextSanitizer.Sanitize(Text(item.History["title"]) ?? item.Cause.Id, limit: 238));
                yield return $"### {id}: {title}\n\n- Type: {EnumText(item.Cause.Type)}\n";
                var diagnostic = DiagnosticTextSanitizer.Sanitize(Text(item.History["error_pattern"]) ?? "", limit: 500);
                yield return "- Diagnostic: " + MarkdownFormatting.CodeSpan(diagnostic) + "\n";
                var issueUrl = Text(item.History["issue_url"]);
                if (issueUrl is not null && System.Text.RegularExpressions.Regex.IsMatch(
                    issueUrl, "^" + System.Text.RegularExpressions.Regex.Escape($"https://github.com/{_repository}/issues/") + @"[1-9][0-9]*\z"))
                {
                    yield return $"- Existing cause issue: {issueUrl}\n";
                }
                if (item.Replayed)
                {
                    // The stored job attribution is an opaque aggregate, not a reversible
                    // list. A later proposal must not replace its original membership.
                    var occurrence = ((JsonArray)item.History["occurrences"]!).First(value =>
                        ReadNormalizedNumber(Field(value, "run_id")) == NormalizedJsonNumber.Parse(runId));
                    var storedJobs = DiagnosticTextSanitizer.Sanitize(Text(occurrence?["job"]) ?? "");
                    if (storedJobs.Length == 0)
                    {
                        yield return "- Stored affected-job attribution is unavailable in the first occurrence.\n";
                    }
                    foreach (var chunk in storedJobs.EnumerateRunes().Chunk(size: 1000))
                    {
                        yield return "- Stored affected jobs (first occurrence): " +
                            MarkdownFormatting.CodeSpan(string.Concat(chunk)) + "\n";
                    }
                    yield return "\n";
                    continue;
                }
                foreach (var job in analysis.Bundle.Jobs.Where(job =>
                    item.Cause.JobIds.Contains(NormalizedJsonNumber.Parse(job.Id.ToString(CultureInfo.InvariantCulture)))))
                {
                    var name = MarkdownFormatting.CodeSpan(DiagnosticTextSanitizer.Sanitize(job.Name, limit: 500));
                    var count = analysis.Bundle.TestEvidence.Failures.Count(test => test.JobId == job.Id);
                    yield return $"- {id} job {job.Id.ToString(CultureInfo.InvariantCulture)}: {name}; " +
                        $"{count.ToString(CultureInfo.InvariantCulture)} observed test failures. " +
                        $"[Job]({analysis.Run.Url}/job/{job.Id.ToString(CultureInfo.InvariantCulture)})\n";
                }
                yield return "\n";
            }
        }
    }
 
    private async Task<string> PublishOverflowAsync(OverflowPublication publication)
    {
        string url;
        if (publication.Existing is null)
        {
            url = await ExecuteEffectAsync(
                "issue-create",
                cause: null,
                destination: null,
                HttpMethod.Post,
                $"repos/{_repository}/issues",
                new JsonObject
                {
                    ["title"] = publication.Title,
                    ["body"] = publication.Body,
                    ["labels"] = new JsonArray("ci-failure-cause"),
                },
                HttpStatusCode.Created);
        }
        else
        {
            url = Text(publication.Existing["html_url"])!;
            await ExecuteEffectAsync(
                "overflow-issue-edit",
                cause: null,
                url,
                HttpMethod.Patch,
                $"repos/{_repository}/issues/{GetValueText(publication.Existing["number"])}",
                new JsonObject { ["body"] = publication.Body, ["title"] = publication.Title },
                HttpStatusCode.OK);
        }
        var number = url[(url.LastIndexOf('/') + 1)..];
        foreach (var page in publication.Continuations)
        {
            var create = page.Id is null;
            await ExecuteEffectAsync(
                create ? "overflow-comment-create" : "overflow-comment-edit",
                cause: null,
                create ? url : url + "#issuecomment-" + page.Id,
                create ? HttpMethod.Post : HttpMethod.Patch,
                create ? $"repos/{_repository}/issues/{number}/comments" : $"repos/{_repository}/issues/comments/{page.Id}",
                new JsonObject { ["body"] = page.Body },
                create ? HttpStatusCode.Created : HttpStatusCode.OK);
        }
        return url;
    }
 
    /// <summary>Holds the completely preflighted aggregate issue and continuation bodies.</summary>
    private sealed record OverflowPublication(JsonObject? Existing, string Body, List<OverflowContinuation> Continuations, string Title);
 
    /// <summary>Identifies an owned continuation to update or a new page to create.</summary>
    private sealed record OverflowContinuation(string? Id, string Body);
}