File: WorkflowRunEvidenceCollector.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.Collections.Immutable;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Aspire.TestTools;
using static AnalyzeCiFailure.CiFailureJson;
 
namespace AnalyzeCiFailure;
 
/// <summary>
/// Collects a selected GitHub Actions run's metadata, failed jobs, logs, annotations, TRX/Mocha artifacts,
/// pull-request or main-branch context, and prior cause records into the local evidence directory.
/// This stage fetches evidence; it does not diagnose failures or publish issues.
/// </summary>
internal sealed partial class WorkflowRunEvidenceCollector
{
    internal const string RunnerLostAnnotation = "The hosted runner lost communication with the server.";
    private readonly Dictionary<string, string> _runnerFailures = new(StringComparer.Ordinal);
    private readonly string _workingDirectory;
    private readonly string _repository;
    private readonly AnalysisScratchWorkspace _scratch;
    private readonly InvocationCapture _capture;
    private readonly IGitHubWorkflowConventions _workflow;
    private readonly IReadOnlyDictionary<string, string>? _commandEnvironment;
    private readonly GitHubApiClient _githubApi;
    private AnalyzerCommandExecutor _command;
    private readonly Dictionary<string, JsonNode?> _documents;
    private readonly Dictionary<string, CollectedAttachment> _attachments;
    private int _started;
    private AnalysisProgress _progress = new();
    private IReadOnlyList<SelectedTestArtifact> _selectedArtifacts = [];
    private EvidenceState _testState = EvidenceState.Unavailable;
    private CollectedPrMetadata? _prMetadata;
    private CollectedMainContext? _mainContext;
 
    private void CaptureDiagnostic(string name, JsonNode? value) => _documents["diagnostics/" + name] = value;
    private void CaptureDiagnostic<T>(string name, T value) => CaptureDiagnostic(name, ToNode(value));
 
    /// <param name="workingDirectory">Repository checkout used for subprocesses and prior-history checkout.</param>
    /// <param name="repository">GitHub repository in owner/name form.</param>
    /// <param name="scratch">Invocation-owned checkout and test-result workspace.</param>
    /// <param name="capture">Caller-owned captures retained even when collection fails.</param>
    /// <param name="workflow">Trusted conventions for the workflow being collected.</param>
    /// <param name="commandEnvironment">Subprocess overrides; null inherits the current environment.</param>
    public WorkflowRunEvidenceCollector(
        string workingDirectory,
        string repository,
        AnalysisScratchWorkspace scratch,
        InvocationCapture capture,
        IGitHubWorkflowConventions workflow,
        IReadOnlyDictionary<string, string>? commandEnvironment,
        GitHubApiClient github)
    {
        if (!GitHubRepositoryNameRegex().IsMatch(repository))
        {
            throw new ValidationException("Invalid analysis repository");
        }
 
        _workingDirectory = workingDirectory;
        _repository = repository;
        _scratch = scratch;
        _capture = capture;
        _documents = capture.Documents;
        _attachments = capture.Attachments;
        _workflow = workflow;
        _commandEnvironment = commandEnvironment;
        _githubApi = github;
        _command = new AnalyzerCommandExecutor(workingDirectory, commandEnvironment);
    }
 
    private static bool IsFailedOrInterrupted(JsonNode? conclusion) => Text(conclusion) is "failure" or "cancelled" or "timed_out";
 
    /// <summary>Collects the selected workflow run's CI failure evidence.</summary>
    /// <param name="request">Repository, run ID, and optional pinned attempt to collect.</param>
    /// <param name="progress">Known identity, work status, and problems retained if collection fails.</param>
    /// <param name="ct">Cancellation token for acquisition and parsing.</param>
    /// <remarks>
    /// The caller supplies inputs and persists the summary. This collector does not read workflow
    /// environment variables or write GitHub Actions step outputs.
    /// </remarks>
    public async Task<EvidenceCollectionResult> CollectAsync(EvidenceCollectionRequest request, AnalysisProgress progress, CancellationToken ct)
    {
        if (Interlocked.Exchange(ref _started, value: 1) != 0)
        {
            throw new InvalidOperationException("A workflow evidence collector can only be used once.");
        }
        var runId = request.RunId;
        var requestedAttempt = request.Attempt;
        _progress = progress;
        progress.Diagnostics.StartCurrentStage();
        ct.ThrowIfCancellationRequested();
        _command = new AnalyzerCommandExecutor(_workingDirectory, _commandEnvironment, ct);
 
        // Resolve and verify the requested run and attempt before capturing their identity.
        var gitHub = new AnalysisGitHubClient(_githubApi, ct);
        var runIdText = runId.ToString(CultureInfo.InvariantCulture);
        progress.Diagnostics.WriteProgress($"Requested source run: https://github.com/{_repository}/actions/runs/{runIdText}" +
            (requestedAttempt is null ? " (latest attempt)" : $" (attempt {requestedAttempt.Value.ToString(CultureInfo.InvariantCulture)})"));
        var runMetadata = await gitHub.GetAsync($"repos/{_repository}/actions/runs/{runIdText}" +
            (requestedAttempt is null ? "" : $"/attempts/{requestedAttempt.Value.ToString(CultureInfo.InvariantCulture)}"));
        CaptureDiagnostic("run.json", runMetadata);
        if (!(runMetadata is JsonObject && GitHubJson.ReadRequiredId(Field(runMetadata, "id")) == runId))
        {
            throw new ValidationException("Trusted run metadata does not match the requested run");
        }
 
        var run = (JsonObject)runMetadata!;
        progress.RunId = runId;
 
        var runAttempt = run["run_attempt"]?.GetValue<int>() ?? throw new ValidationException("Trusted run metadata is missing its attempt");
        if (runAttempt <= 0)
        {
            throw new ValidationException("Trusted run metadata must contain a positive attempt.");
        }
        if (requestedAttempt is not null && runAttempt != requestedAttempt)
        {
            throw new ValidationException("Trusted run metadata does not match the requested attempt");
        }
 
        progress.RunAttempt = runAttempt;
 
        // Admit supported CI runs; success or an unsupported scope is legitimate no-work.
        if (!(Text(run["path"]) == _workflow.WorkflowPath))
        {
            throw new ValidationException($"Run {runId} belongs to workflow '{Display(Text(run["path"]) ?? "")}', not '{Display(_workflow.WorkflowPath)}'");
        }
        RunScope? runScope = Text(run["event"]) switch
        {
            "push" when Text(run["head_branch"]) == "main" => RunScope.Main,
            "pull_request" or "pull_request_target" => RunScope.PullRequest,
            _ => null,
        };
        if (runScope is null || Text(run["conclusion"]) == "success")
        {
            progress.Diagnostics.Record(
                "run-admission",
                runScope is null ? "unsupported-scope" : "successful-run",
                new { workflow = _workflow.WorkflowPath });
            Console.WriteLine("::notice::Run succeeded or has an unsupported scope. Skipping analysis.");
            progress.HasRelevantFailures = false;
            return new(Run: null, HasRelevantFailures: false);
        }
 
        // Associate the subject PR and select actionable failures, excluding aggregation wrappers.
        progress.HeadSha = Text(run["head_sha"]);
        progress.RunUrl = Text(run["html_url"]);
        progress.RunScope = runScope;
        var subjectPrNumber = runScope == RunScope.PullRequest ? await FindAssociatedPullRequestNumberAsync(gitHub, run) : null;
        progress.SubjectPrNumber = subjectPrNumber;
 
        progress.Stage = "jobs";
        var allJobs = await gitHub.GetRowsAsync($"repos/{_repository}/actions/runs/{runIdText}/attempts/{runAttempt.ToString(CultureInfo.InvariantCulture)}/jobs", "jobs");
        CaptureDiagnostic("all-jobs.json", allJobs);
        var failedJobs = new JsonArray(allJobs.OfType<JsonObject>().Where(job =>
                IsFailedOrInterrupted(job["conclusion"]) && !_workflow.IsAggregationJob(job))
            .Select(job => job.DeepClone()).ToArray());
        CaptureDiagnostic("failed-jobs.json", failedJobs);
        progress.Diagnostics.Record(
            "jobs",
            failedJobs.Count == 0 ? "no-actionable-jobs" : "actionable",
            new
            {
                total = allJobs.Count,
                actionable = failedJobs.Count,
                failed_or_interrupted = allJobs.Count(job => IsFailedOrInterrupted(Field(job, "conclusion"))),
                excluded = allJobs.Count - failedJobs.Count
            });
        if (failedJobs.Count == 0)
        {
            progress.HasRelevantFailures = false;
            return new(Run: null, HasRelevantFailures: false);
        }
 
        progress.HasRelevantFailures = true;
        ct.ThrowIfCancellationRequested();
        var jobs = CollectionProjection.ProjectJobs(failedJobs);
 
        // Enrich diagnosis with branch/PR context, focused logs, annotations, and retry patterns.
        progress.Stage = "context";
        if (runScope == RunScope.Main)
        {
            await CollectMainBranchComparisonContextAsync(gitHub, run, runId);
        }
        await CollectFailedJobLogsAndAnnotationsAsync(gitHub, failedJobs);
        jobs = jobs.Select(job => job with
        {
            RunnerFailure = _runnerFailures.GetValueOrDefault(job.Id.ToString(CultureInfo.InvariantCulture)),
        }).ToImmutableArray();
        if (subjectPrNumber is { } pullRequestNumber)
        {
            await CollectPullRequestContextAsync(gitHub, pullRequestNumber);
        }
 
        var retryPatternsPath = Path.Combine(_workingDirectory, "eng", "test-retry-patterns.json");
        if (File.Exists(retryPatternsPath))
        {
            var attachment = new CollectedAttachment((await File.ReadAllBytesAsync(retryPatternsPath, ct)).ToImmutableArray());
            try
            {
                CaptureDiagnostic("retry-patterns.json", Parse(attachment.Text));
            }
            catch (JsonException error)
            {
                progress.RecordProblem(AnalysisProblemCategory.OptionalContext, "retry-patterns", error.Message);
                Console.WriteLine($"::warning::Unable to parse retry patterns: {Display(DiagnosticTextSanitizer.Sanitize(error.Message, limit: 500))}");
                _attachments["diagnostics/retry-patterns.json"] = attachment;
            }
        }
 
        // Keep history acquisition separate: a history failure is fatal, not degraded test evidence.
        progress.Stage = "history";
        await CopyPriorCauseHistoryAsync();
        ct.ThrowIfCancellationRequested();
 
        // Download and parse bounded test artifacts; supported test-evidence failures may degrade diagnosis.
        progress.Stage = "test-evidence";
        await DownloadAndExtractTestResultArtifactsAsync(gitHub, run, failedJobs, runId, runAttempt);
        ImmutableArray<CollectedTestFailure> failures = [];
        if (_selectedArtifacts.Count > 0)
        {
            const int maximumMochaDiagnosticCharacters = 65536;
            var mochaDiagnostics = new Dictionary<string, (StringBuilder Text, int Characters, int Omitted)>(StringComparer.Ordinal);
            try
            {
                var raw = TestFailureReader.ReadAvailable(
                    _scratch.Results,
                    jobs,
                    _selectedArtifacts,
                    message =>
                    {
                        ReportArtifactProblem(AnalysisProblemCategory.TestEvidence, "parsing", message);
                        _testState = EvidenceState.Unavailable;
                    },
                    (artifact, diagnostic) =>
                    {
                        if (!mochaDiagnostics.TryGetValue(artifact.JobName, out var log))
                        {
                            log = (new StringBuilder(), 0, 0);
                        }
                        // Keep complete, independently redacted entries. After filling the buffer,
                        // count omissions without rescanning old text or sanitizing discarded entries.
                        if (log.Omitted > 0 || log.Characters == maximumMochaDiagnosticCharacters)
                        {
                            log.Omitted++;
                        }
                        else
                        {
                            var entry = "Mocha hook/harness diagnostic: not a completed test failure.\n" +
                                DiagnosticTextSanitizer.Sanitize(diagnostic, limit: 2000, multiline: true) + "\n";
                            var characters = entry.EnumerateRunes().Count();
                            if (log.Characters + characters > maximumMochaDiagnosticCharacters)
                            {
                                log.Omitted++;
                            }
                            else
                            {
                                log.Text.Append(entry);
                                log.Characters += characters;
                            }
                        }
                        mochaDiagnostics[artifact.JobName] = log;
                    });
                var conflicts = raw.Where(test => !string.IsNullOrEmpty(test.Source.ExecutionId))
                    .GroupBy(test => (test.JobId, test.Source.ExecutionId))
                    .Where(group => group.Select(test => (test.Name, test.Diagnostics)).Distinct().Skip(1).Any()).ToArray();
                foreach (var conflict in conflicts)
                {
                    var artifact = _selectedArtifacts.Single(selection =>
                        GitHubJson.ReadRequiredId(selection.Artifact.Id) == conflict.Last().Source.ArtifactId);
                    ReportArtifactProblem(
                        AnalysisProblemCategory.TestEvidence,
                        "parsing",
                        $"Conflicting copied executions were excluded; job={conflict.Key.JobId.ToString(CultureInfo.InvariantCulture)} " +
                        $"execution='{Display(DiagnosticTextSanitizer.Sanitize(conflict.Key.ExecutionId ?? "", limit: 80))}' " +
                        $"artifacts={string.Join(',', conflict.Select(test => test.Source.ArtifactId.ToString(CultureInfo.InvariantCulture)).Distinct())}; " +
                        TestResultArtifacts.Describe(artifact.Expectation));
                    _testState = EvidenceState.Unavailable;
                }
                var conflictingExecutions = conflicts.Select(group => group.Key).ToHashSet();
                failures = TestFailureReader.ConsolidateFailures(raw.Where(test =>
                    !conflictingExecutions.Contains((test.JobId, test.Source.ExecutionId))).ToImmutableArray());
            }
            catch (Exception ex) when (ex is ValidationException or JsonException or IOException or System.Xml.XmlException)
            {
                ReportArtifactProblem(AnalysisProblemCategory.TestEvidence, "parsing", ex.Message);
                _testState = EvidenceState.Unavailable;
            }
            finally
            {
                foreach (var (jobName, log) in mochaDiagnostics)
                {
                    var job = jobs.Single(job => job.Name == jobName);
                    if (log.Omitted > 0)
                    {
                        log.Text.Append(CultureInfo.InvariantCulture,
                            $"\n[Omitted {log.Omitted} further Mocha hook/harness diagnostic entries after reaching the text limit.]\n");
                    }
                    var name = $"diagnostics/job-{job.Id.ToString(CultureInfo.InvariantCulture)}-mocha.log";
                    _attachments[name] = CollectedAttachment.FromText(log.Text.ToString());
                    _progress.Diagnostics.Record("mocha-non-test-failure", log.Omitted > 0 ? "truncated" : "retained",
                        new { job = job.Id, omitted_entries = log.Omitted });
                }
            }
        }
 
        // Freeze captured facts and attachments; the command boundary owns all output writes.
        ct.ThrowIfCancellationRequested();
        progress.Stage = "projection";
        var prior = PriorCauseCatalogue.BuildIndex(_attachments.ToImmutableDictionary(), failures);
        var evidence = CollectionProjection.CreateBundle(request.Repository, progress, run, jobs, failures, _testState, prior, _prMetadata, _mainContext);
        progress.Diagnostics.Record("test-evidence", EnumText(_testState), new { failures = failures.Length, acquired_artifacts = _selectedArtifacts.Count });
        _capture.Bundle = evidence;
        AnalysisValidator.ValidateBundle(evidence, request.Repository);
        var captured = _documents.ToImmutableDictionary(pair => pair.Key, pair => JsonSerializer.SerializeToElement(pair.Value));
        return new(new CollectedRun(evidence, _attachments.ToImmutableDictionary(), prior) { JsonContext = captured }, HasRelevantFailures: true);
    }
 
    private async Task CopyPriorCauseHistoryAsync()
    {
        var history = new CauseHistoryStore(_command, _repository, _scratch.HistoryCheckout, _progress.Diagnostics);
        if (await history.OpenForReadAsync())
        {
            _capture.HistoryCommit = await history.GetHeadCommitAsync();
            foreach (var causePath in history.EnumerateCauseFiles())
            {
                _attachments["history/causes/" + Path.GetFileName(causePath)] =
                    new((await File.ReadAllBytesAsync(causePath)).ToImmutableArray());
            }
        }
    }
 
    private async Task<long?> FindAssociatedPullRequestNumberAsync(AnalysisGitHubClient gitHub, JsonObject run)
    {
        static (long? Number, bool Ambiguous) SelectSinglePullRequestNumber(IEnumerable<JsonNode?> candidates)
        {
            var numbers = candidates.OfType<JsonValue>()
                .Select(GitHubJson.ReadRequiredId).Distinct().ToArray();
            return (numbers.Length == 1 ? numbers[0] : null, numbers.Length > 1);
        }
        var (subjectPrNumber, ambiguous) = SelectSinglePullRequestNumber((run["pull_requests"] as JsonArray ?? []).Where(pullRequest =>
            Text(Field(Field(Field(pullRequest, "base"), "repo"), "url")) == $"https://api.github.com/repos/{_repository}")
            .Select(pullRequest => Field(pullRequest, "number")));
        var method = "run-metadata";
        var mismatchedHead = false;
        var headSha = Text(run["head_sha"]) ?? "";
        if (subjectPrNumber is null && !ambiguous && headSha.Length > 0)
        {
            method = "commit";
            var associatedPullRequests = await gitHub.GetRowsAsync($"repos/{_repository}/commits/{headSha}/pulls?per_page=100");
            (subjectPrNumber, ambiguous) = SelectSinglePullRequestNumber(associatedPullRequests.Where(pullRequest => Text(Field(Field(Field(pullRequest, "base"), "repo"), "full_name")) == _repository)
                .Select(pullRequest => Field(pullRequest, "number")));
        }
        var headOwner = Text(Field(Field(run["head_repository"], "owner"), "login")) ?? "";
        var headBranch = Text(run["head_branch"]) ?? "";
        if (subjectPrNumber is null && !ambiguous && headSha.Length > 0 && headOwner.Length > 0 && headBranch.Length > 0)
        {
            method = "head-branch";
            var associatedPullRequests = await gitHub.GetRowsAsync(
                $"repos/{_repository}/pulls?state=all&per_page=100&head={Uri.EscapeDataString($"{headOwner}:{headBranch}")}");
            mismatchedHead = associatedPullRequests.Count > 0 && !associatedPullRequests.Any(pullRequest => Text(Field(Field(pullRequest, "head"), "sha")) == headSha);
            (subjectPrNumber, ambiguous) = SelectSinglePullRequestNumber(associatedPullRequests.Where(pullRequest => Text(Field(Field(pullRequest, "head"), "sha")) == headSha).Select(pullRequest => Field(pullRequest, "number")));
        }
        if (ambiguous)
        {
            _progress.Diagnostics.Record("subject-pr", "ambiguous", new { method });
            Console.WriteLine("::warning::Multiple associated PRs found. Analysis will proceed without subject PR context.");
            return null;
        }
 
        _progress.Diagnostics.Record(
            "subject-pr",
            subjectPrNumber is not null ? "resolved" : mismatchedHead ? "head-sha-mismatch" : "no-match",
            new { method, number = subjectPrNumber?.ToString(CultureInfo.InvariantCulture) });
        return subjectPrNumber;
    }
 
    private async Task CollectMainBranchComparisonContextAsync(AnalysisGitHubClient gitHub, JsonObject run, long runId)
    {
        var mainHistory = new BranchHistoryReader(gitHub, _repository);
        var headSha = Text(run["head_sha"]) ?? "";
        JsonObject triggeringMergePr = [];
        try
        {
            var mergedPullRequests = mainHistory.FindMergedPullRequests(await gitHub.GetRowsAsync($"repos/{_repository}/commits/{headSha}/pulls?per_page=100"));
            if (mergedPullRequests.Length == 1)
            {
                triggeringMergePr = mergedPullRequests[0];
            }
        }
        catch (Exception ex) when (ex is ValidationException or JsonException)
        {
            _progress.RecordProblem(AnalysisProblemCategory.OptionalContext, "triggering-merge", ex.Message);
            Console.WriteLine($"::warning::Unable to associate the triggering merge: {Display(ex.Message)}");
        }
        CaptureDiagnostic("triggering-merge-pr.json", triggeringMergePr);
        JsonObject lastSuccessfulRun = [];
        try
        {
            lastSuccessfulRun = await mainHistory.FindLastSuccessfulRunAsync(
                GetValueText(run["workflow_id"]),
                Text(run["created_at"]) ?? "",
                runId);
        }
        catch (Exception ex) when (ex is ValidationException or JsonException or FormatException)
        {
            _progress.RecordProblem(AnalysisProblemCategory.OptionalContext, "main-history", ex.Message);
            Console.WriteLine($"::warning::Unable to find the last successful main run. Continuing without a candidate merge range: {Display(ex.Message)}");
        }
        CaptureDiagnostic("last-successful-main-run.json", lastSuccessfulRun);
        var (candidateMerges, historyState) = await mainHistory.FindCandidateMergesAsync(Text(lastSuccessfulRun["head_sha"]) ?? "", headSha);
        _mainContext = CollectionProjection.ProjectMainContext(headSha, lastSuccessfulRun, triggeringMergePr, candidateMerges, historyState);
        CaptureDiagnostic("candidate-merges.json", candidateMerges);
        CaptureDiagnostic("candidate-merge-history-status.json", new CandidateHistoryStatus { State = historyState });
    }
 
    private async Task CollectFailedJobLogsAndAnnotationsAsync(AnalysisGitHubClient gitHub, JsonArray failedJobs)
    {
        foreach (var job in failedJobs)
        {
            var jobId = GetValueText(job!["id"]);
            GitHubTextDownload logResult;
            try
            {
                logResult = await gitHub.DownloadTextAsync($"repos/{_repository}/actions/jobs/{jobId}/logs", maximumBytes: 10485760);
            }
            catch (ValidationException error)
            {
                logResult = new GitHubTextDownload("", error.Message);
            }
            var logText = logResult.Text;
            if (logResult.Error is not null)
            {
                var problem = $"Unable to fetch complete logs for job {jobId}: " +
                    DiagnosticTextSanitizer.Sanitize(logResult.Error, limit: 300, multiline: false);
                _progress.RecordProblem(AnalysisProblemCategory.OptionalContext, "logs", problem);
                Console.WriteLine($"::warning::{Display(problem)}");
            }
            var safeLog = DiagnosticTextSanitizer.Sanitize(logText, limit: 10485760, multiline: true);
            var focused = FocusLogs(safeLog, out var sourceLines, out var retainedLines);
            _progress.Diagnostics.Record(
                "job-log",
                logResult.Error is null ? "acquired" : "partial",
                new
                {
                    job_id = jobId,
                    complete = logResult.Error is null,
                    source_lines = sourceLines,
                    retained_lines = retainedLines,
                    truncated = retainedLines < sourceLines || logText.EnumerateRunes().Count() > 10485760,
                    sanitized = true,
                    focused = true
                });
            if (logResult.Error is not null)
            {
                // Acquisition status is not a source-log line: append it after focusing so
                // the excerpt budget cannot hide a partial or unavailable download.
                focused += $"\nFailed to fetch complete logs for job {jobId}.\n" +
                    DiagnosticTextSanitizer.Sanitize(logResult.Error, limit: 500, multiline: true);
            }
            _attachments[$"diagnostics/job-{jobId}.log"] = CollectedAttachment.FromText(focused);
            JsonArray annotations = [];
            // GitHub supplies URLs such as https://api.github.com/repos/owner/repo/check-runs/88.
            var checkRunUrl = Text(job["check_run_url"]) ?? "";
            if (long.TryParse(
                checkRunUrl.AsSpan(checkRunUrl.LastIndexOf('/') + 1),
                NumberStyles.None,
                CultureInfo.InvariantCulture,
                out var checkRunId) && checkRunId > 0)
            {
                try
                {
                    annotations = await gitHub.GetRowsAsync($"repos/{_repository}/check-runs/{checkRunId.ToString(CultureInfo.InvariantCulture)}/annotations");
                }
                catch (Exception ex) when (ex is ValidationException or JsonException)
                {
                    _progress.RecordProblem(AnalysisProblemCategory.OptionalContext, "annotations", ex.Message);
                    Console.WriteLine($"::warning::Unable to load job annotations: {Display(ex.Message)}");
                }
            }
            CaptureDiagnostic($"annotations-{jobId}.json", annotations);
            // Workflow commands can emit arbitrary annotation messages. Require an
            // interrupted step as independent evidence before exempting missing results.
            if ((job["steps"] as JsonArray ?? []).Any(step => Text(Field(step, "status")) == "in_progress") &&
                annotations.OfType<JsonObject>().Any(annotation =>
                Text(annotation["message"])?.StartsWith(RunnerLostAnnotation, StringComparison.Ordinal) == true))
            {
                _runnerFailures[jobId] = RunnerLostAnnotation;
            }
        }
    }
 
    internal static string FocusLogs(string log) => FocusLogs(log, out _, out _);
 
    private static string FocusLogs(string log, out int sourceLines, out int retainedLines)
    {
        var lines = log.Split('\n');
        if (lines[^1].Length == 0)
        {
            lines = lines[..^1];
        }
        sourceLines = lines.Length;
        var matches = lines.Select(line => !PassingTestRegex().IsMatch(line) && LogFailureRegex().IsMatch(line)).ToArray();
        var selected = new SortedSet<int>();
        var anchors = Enumerable.Range(start: 0, lines.Length).Where(index => matches[index])
            .OrderByDescending(index => ExplicitFailureRegex().IsMatch(lines[index])).ThenBy(index => index).ToArray();
        // Reserve actual failure lines before context or generic keyword matches can
        // exhaust the excerpt. Render in source order only after selection.
        foreach (var group in anchors.GroupBy(index => ExplicitFailureRegex().IsMatch(lines[index])))
        {
            foreach (var index in group)
            {
                TryAdd(index);
            }
            foreach (var index in group)
            {
                for (var offset = -3; offset <= 5; offset++)
                {
                    if (index + offset >= 0 && index + offset < lines.Length)
                    {
                        TryAdd(index + offset);
                    }
                }
            }
        }
        void TryAdd(int index)
        {
            if (!selected.Add(index))
            {
                return;
            }
            var count = selected.Count;
            var previous = -1;
            foreach (var line in selected)
            {
                if (previous >= 0 && line > previous + 1)
                {
                    count++;
                }
                previous = line;
            }
            if (count > 150)
            {
                selected.Remove(index);
            }
        }
        var output = new List<(string Text, bool Source)>();
        var previous = -1;
        foreach (var i in selected)
        {
            if (previous >= 0 && i > previous + 1)
            {
                output.Add(("--", false));
            }
            output.Add(($"{i + 1}{(matches[i] ? ':' : '-')}{lines[i]}", true));
            previous = i;
        }
 
        if (output.Count == 0)
        {
            retainedLines = Math.Min(200, lines.Length);
            return string.Join('\n', lines.TakeLast(count: 200)) + "\n";
        }
        var kept = output.Take(150).ToArray();
        retainedLines = kept.Count(line => line.Source);
        return string.Join('\n', kept.Select(line => line.Text)) + "\n";
    }
 
    private async Task CollectPullRequestContextAsync(AnalysisGitHubClient gitHub, long pullRequestNumber)
    {
        var subjectPrNumber = pullRequestNumber.ToString(CultureInfo.InvariantCulture);
        try
        {
            var files = await gitHub.GetRowsAsync($"repos/{_repository}/pulls/{subjectPrNumber}/files");
            CaptureDiagnostic(
                "pr-files.json",
                new JsonArray(files.Select(file => (JsonNode)new JsonObject(
                new[] { "filename", "status", "additions", "deletions", "changes" }.Select(fieldName => KeyValuePair.Create(fieldName, Field(file, fieldName)?.DeepClone())))).ToArray()));
        }
        catch (Exception ex) when (ex is ValidationException or JsonException)
        {
            _progress.RecordProblem(AnalysisProblemCategory.OptionalContext, "pr-files", ex.Message);
            Console.WriteLine($"::warning::Unable to load PR files: {Display(ex.Message)}");
            CaptureDiagnostic("pr-files.json", new JsonArray());
        }
        try
        {
            var pullRequest = await gitHub.GetAsync($"repos/{_repository}/pulls/{subjectPrNumber}");
            if (!(pullRequest is JsonObject))
            {
                throw new ValidationException("GitHub returned invalid PR metadata");
            }
            if (GitHubJson.ReadRequiredId(pullRequest!["number"]) != pullRequestNumber)
            {
                throw new ValidationException("GitHub PR metadata does not match the known subject.");
            }
            _prMetadata = CollectionProjection.ProjectPullRequest(pullRequest);
            CaptureDiagnostic(
                "pr-metadata.json",
                new JsonObject
                {
                    ["number"] = pullRequest!["number"]?.DeepClone(),
                    ["title"] = pullRequest["title"]?.DeepClone(),
                    ["state"] = pullRequest["state"]?.DeepClone(),
                    ["locked"] = pullRequest["locked"]?.DeepClone(),
                    ["user"] = Field(pullRequest["user"], "login")?.DeepClone(),
                    ["head_branch"] = Field(pullRequest["head"], "ref")?.DeepClone(),
                    ["base_branch"] = Field(pullRequest["base"], "ref")?.DeepClone(),
                    ["html_url"] = pullRequest["html_url"]?.DeepClone(),
                });
        }
        catch (Exception ex) when (ex is ValidationException or JsonException)
        {
            _progress.RecordProblem(AnalysisProblemCategory.OptionalContext, "pr-metadata", ex.Message);
            Console.WriteLine($"::warning::Unable to load PR metadata: {Display(ex.Message)}");
            CaptureDiagnostic("pr-metadata.json", new JsonObject());
        }
    }
 
    private async Task DownloadAndExtractTestResultArtifactsAsync(
        AnalysisGitHubClient gitHub,
        JsonObject run,
        JsonArray failedJobs,
        long runId,
        int runAttempt)
    {
        var evidenceState = EvidenceState.Unavailable;
        var zipDirectory = _scratch.Archives;
        try
        {
            var artifacts = await gitHub.GetRowsAsync($"repos/{_repository}/actions/runs/{runId.ToString(CultureInfo.InvariantCulture)}/artifacts", "artifacts");
            CaptureDiagnostic("artifacts.json", artifacts);
            var plans = new List<TestArtifactExpectation>();
            foreach (var job in failedJobs.OfType<JsonObject>())
            {
                foreach (var plan in _workflow.DescribeTestArtifacts(job, runAttempt))
                {
                    if (plan.JobName != Text(job["name"]))
                    {
                        throw new ValidationException("Workflow conventions cannot change a collected test job's identity: " +
                            $"job='{Display(DiagnosticTextSanitizer.Sanitize(Text(job["name"]) ?? "", limit: 80))}'; " +
                            $"returned {TestResultArtifacts.Describe(plan)}");
                    }
                    plans.Add(plan);
                }
            }
            var explainedAbsences = plans.Where(plan => ExplainedRunnerLoss(plan.JobName) &&
                !artifacts.OfType<JsonObject>().Any(artifact => Text(artifact["name"]) == plan.ArtifactName &&
                    (Text(artifact["created_at"]) is not { } created ||
                        string.CompareOrdinal(created, Text(run["run_started_at"]) ?? "") > 0 &&
                        string.CompareOrdinal(created, Text(run["updated_at"]) ?? "") <= 0))).ToHashSet();
            var selectionProblems = new HashSet<TestArtifactExpectation>();
            var selectedTestArtifacts = TestResultArtifacts.SelectAvailable(
                TestResultArtifacts.ReadArtifacts(artifacts),
                plans,
                Text(run["run_started_at"]) ?? "",
                Text(run["updated_at"]) ?? "",
                _progress.Diagnostics,
                (plan, message) =>
                {
                    if (message.StartsWith("test result artifact is missing", StringComparison.Ordinal) &&
                        explainedAbsences.Contains(plan))
                    {
                        _progress.Diagnostics.Record("artifact-selection", "runner-loss", new { job = plan.JobName, artifact = plan.ArtifactName });
                        return;
                    }
                    selectionProblems.Add(plan);
                    ReportArtifactProblem(
                        plan.RequiresResults ? AnalysisProblemCategory.TestEvidence : AnalysisProblemCategory.OptionalContext,
                        "artifacts",
                        $"{message}; {TestResultArtifacts.Describe(plan)}");
                });
            _progress.Diagnostics.Record(
                "artifact-selection",
                "selected",
                new { available = artifacts.Count, planned = plans.Count, selected = selectedTestArtifacts.Count });
            foreach (var plan in plans)
            {
                var selected = selectedTestArtifacts.Where(selection => selection.Expectation == plan).ToArray();
                _progress.Diagnostics.Record(
                    "artifact-plan",
                    selected.Length == 0 ? "missing-or-ineligible" : "matched",
                    new
                    {
                        job = plan.JobName,
                        artifact = plan.ArtifactName,
                        ids = selected.Select(selection => GetValueText(selection.Artifact.Id)).ToArray()
                    });
            }
            var acquiredArtifacts = new List<SelectedTestArtifact>();
            _selectedArtifacts = acquiredArtifacts;
            var selectedArtifacts = TestResultArtifacts.SerializeSelection(selectedTestArtifacts);
            CaptureDiagnostic("selected-test-result-artifacts.json", selectedArtifacts);
            evidenceState = selectedArtifacts.Count == 0 ? EvidenceState.NotApplicable : EvidenceState.Complete;
            foreach (var plan in plans.Where(plan => plan.RequiresResults &&
                !selectedTestArtifacts.Any(artifact => artifact.Expectation == plan) && !explainedAbsences.Contains(plan)))
            {
                evidenceState = EvidenceState.Unavailable;
                if (!selectionProblems.Contains(plan))
                {
                    ReportArtifactProblem(
                    AnalysisProblemCategory.TestEvidence,
                    "artifacts",
                    $"Structured test results are unavailable; {TestResultArtifacts.Describe(plan)}");
                }
            }
            Directory.CreateDirectory(zipDirectory);
            long remainingExpandedBytes = 1073741824;
            long remainingDownloadedBytes = 1073741824;
            foreach (var selection in selectedTestArtifacts)
            {
                var artifactId = GetValueText(selection.Artifact.Id);
                var format = EnumText(selection.Format);
                var zipPath = Path.Combine(zipDirectory, artifactId + ".zip");
                try
                {
                    if (remainingExpandedBytes <= 0 || remainingDownloadedBytes <= 0)
                    {
                        throw new ValidationException("The shared artifact acquisition budget has been exhausted.");
                    }
                    var artifactMetadata = artifacts.First(candidate => ReadNormalizedNumber(Field(candidate, "id")) == ReadNormalizedNumber(selection.Artifact.Id));
                    if (Field(artifactMetadata, "workflow_run") is JsonObject associatedRun)
                    {
                        if (!(ReadNormalizedNumber(associatedRun["id"]) == ReadNormalizedNumber(run["id"]) && Text(associatedRun["head_sha"]) == Text(run["head_sha"])))
                        {
                            throw new ValidationException("Test result artifact does not match the trusted run and SHA");
                        }
                    }
                    try
                    {
                        await gitHub.DownloadFileAsync(
                            $"repos/{_repository}/actions/artifacts/{artifactId}/zip",
                            zipPath,
                            maximumBytes: Math.Min(104857600, remainingDownloadedBytes));
                    }
                    catch (ValidationException error)
                    {
                        throw new ValidationException($"Unable to download test result artifact: {error.Message}", error);
                    }
                    TestResultArchiveExtractor.ExtractTracked(
                        zipPath,
                        Path.Combine(_scratch.Results, artifactId),
                        remainingExpandedBytes,
                        (long)double.Parse(GetValueText(selection.Artifact.SizeInBytes), CultureInfo.InvariantCulture),
                        format,
                        count => remainingExpandedBytes -= count);
                    acquiredArtifacts.Add(selection);
                    _progress.Diagnostics.Record("artifact-acquisition", "extracted", new { id = artifactId, format });
                }
                catch (Exception ex) when (ex is ValidationException or IOException or InvalidDataException)
                {
                    _progress.Diagnostics.Record("artifact-acquisition", "failed", new { id = artifactId, format, reason = ex.Message });
                    ReportArtifactProblem(
                        selection.RequiresResults
                        ? AnalysisProblemCategory.TestEvidence : AnalysisProblemCategory.OptionalContext,
                        "artifacts",
                        $"Unable to extract test results artifact: {DiagnosticTextSanitizer.Sanitize(ex.Message, limit: 200)}; " +
                        $"{TestResultArtifacts.Describe(selection.Expectation)} id={artifactId}");
                    if (selection.RequiresResults)
                    {
                        evidenceState = EvidenceState.Unavailable;
                    }
                }
                finally
                {
                    // Failed optional downloads also consumed the acquisition budget.
                    // The shared transport retains bounded partial files for this accounting.
                    if (File.Exists(zipPath))
                    {
                        remainingDownloadedBytes -= new FileInfo(zipPath).Length;
                    }
                }
            }
        }
        catch (Exception ex) when (ex is ValidationException or JsonException or IOException)
        {
            evidenceState = EvidenceState.Unavailable;
            ReportArtifactProblem(
                AnalysisProblemCategory.TestEvidence,
                "artifacts",
                $"Failed to collect test result artifacts: {ex.Message}");
        }
        _testState = evidenceState;
 
        bool ExplainedRunnerLoss(string jobName)
        {
            var matches = failedJobs.OfType<JsonObject>().Where(job => Text(job["name"]) == jobName).ToArray();
            return matches.Length == 1 && _runnerFailures.ContainsKey(GetValueText(matches[0]["id"]));
        }
    }
 
    private void ReportArtifactProblem(AnalysisProblemCategory category, string stage, string message)
    {
        var context = $"run=https://github.com/{_repository}/actions/runs/{_progress.RunId} " +
            $"attempt={_progress.RunAttempt} workflow={DiagnosticTextSanitizer.Sanitize(_workflow.WorkflowPath, limit: 80)} " +
            $"stage={stage}: {message}";
        var safeMessage = Display(DiagnosticTextSanitizer.Sanitize(context, limit: 1000));
        _progress.RecordProblem(category, stage, safeMessage);
        Console.WriteLine($"::warning::{safeMessage}");
    }
 
    [GeneratedRegex(@"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z")]
    private static partial Regex GitHubRepositoryNameRegex();
 
    // Node reports assertions as "AssertionError [ERR_ASSERTION]: web entered FailedToStart"
    // with the useful message several lines before the final Actions exit-code annotation.
    [GeneratedRegex(@"error [A-Z]{2,}[0-9]|##\[error\]|\bAssertionError(?:\s*\[[A-Z0-9_]+\])?:|\bFAILED\b|\bfailed!\b|Build FAILED|ECONNRESET|ECONNREFUSED|ENOTFOUND|Connection reset by peer|Could not resolve host|Operation timed out|The SSL connection could not be established|The requested URL returned error|403 Forbidden|exit code [1-9]|Process completed with exit code",
        RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    private static partial Regex LogFailureRegex();
 
    // Mocha emits "2026-10-29T01:00:00Z   ✔ handles failed operations".
    // The test name can mention errors without being a failed test result.
    [GeneratedRegex(@"^\s*(?:\d{4}-\d\d-\d\dT\S+\s+)?[\u2713\u2714]\s", RegexOptions.CultureInvariant)]
    private static partial Regex PassingTestRegex();
 
    [GeneratedRegex(@"##\[error\]|\bAssertionError(?:\s*\[[A-Z0-9_]+\])?:|error [A-Z]{2,}[0-9]|Build FAILED",
        RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
    private static partial Regex ExplicitFailureRegex();
}