File: AnalysisPublisher.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.Nodes;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using System.Net;
using Aspire.TestTools;
using static AnalyzeCiFailure.CiFailureJson;
using static AnalyzeCiFailure.ValidationRules;
 
namespace AnalyzeCiFailure;
 
/// <summary>
/// Publishes validated causes as GitHub issues, records occurrences and issue links on the cause-history
/// branch, and updates applicable PR comments. Fresh history and issue destinations are checked before writes;
/// an artifact claiming to be "validated" is never a publication authority.
/// </summary>
internal sealed partial class AnalysisPublisher
{
    private readonly string _requestDirectory;
    private readonly string _historyDirectory;
    private readonly string _repository;
    private readonly string _observedAt;
    private readonly string _workingDirectory;
    private AnalyzerCommandExecutor _command;
    private readonly AnalysisDiagnostics _diagnostics;
    private readonly GitHubApiClient _githubApi;
    private AnalysisGitHubClient _github;
    private string? _overflowIssueUrl;
 
    /// <param name="workingDirectory">Repository checkout used for GitHub and Git subprocesses.</param>
    /// <param name="requestDirectory">Directory used for temporary issue bodies and PR comment requests.</param>
    /// <param name="historyDirectory">Local checkout of the cause-history branch.</param>
    /// <param name="repository">GitHub repository in owner/name form.</param>
    /// <param name="observedAt">Timestamp recorded for new run occurrences.</param>
    /// <param name="diagnostics">Caller-owned decisions and confirmed publication receipts.</param>
    internal AnalysisPublisher(
        string workingDirectory,
        string requestDirectory,
        string historyDirectory,
        string repository,
        string observedAt,
        AnalysisDiagnostics diagnostics,
        GitHubApiClient github)
    {
        if (!GitHubRepositoryNameRegex().IsMatch(repository))
        {
            throw new ValidationException("Invalid publication repository");
        }
        if (!DateTimeOffset.TryParse(
            observedAt,
            System.Globalization.CultureInfo.InvariantCulture,
            System.Globalization.DateTimeStyles.None,
            out _))
        {
            throw new ValidationException("Invalid observation timestamp");
        }
 
        _requestDirectory = requestDirectory;
        _historyDirectory = historyDirectory;
        _repository = repository;
        _observedAt = observedAt;
        _workingDirectory = workingDirectory;
        _command = new AnalyzerCommandExecutor(workingDirectory);
        _diagnostics = diagnostics;
        _githubApi = github;
        _github = new AnalysisGitHubClient(github, CancellationToken.None);
    }
 
    public async Task PublishAsync(ValidatedAnalysis analysis, CancellationToken cancellationToken)
    {
        _overflowIssueUrl = null;
        cancellationToken.ThrowIfCancellationRequested();
        _command = new AnalyzerCommandExecutor(_workingDirectory, cancellationToken: cancellationToken);
        _github = new AnalysisGitHubClient(_githubApi, cancellationToken);
        Directory.CreateDirectory(_requestDirectory);
 
        // Deterministic PR failures produce a comment, not recurring cause-history records.
        if (analysis.Report.Verdict == AnalysisVerdict.CodeIssue)
        {
            _diagnostics.Record("publication", "comment-only", new { verdict = analysis.Report.Verdict });
            await PublishCommentAsync(analysis);
            return;
        }
        _diagnostics.Record("publication", "recurring-history", new { verdict = analysis.Report.Verdict, causes = analysis.Causes.Length });
 
        // Preflight every cause and destination against fresh history before persistent writes.
        var history = new CauseHistoryStore(_command, _repository, _historyDirectory, _diagnostics);
        await history.OpenForPublicationAsync();
 
        var runId = JsonNode.Parse(analysis.Evidence.Context.RunId!.Value.GetRawText())!;
        var prepared = PrepareCauses(analysis, runId, history);
        if (UsesAggregatePublication(prepared.Count))
        {
            var storedRunPath = history.RunPath(GetValueText(runId));
            var aggregate = await PrepareOverflowAsync(
                analysis,
                prepared,
                File.Exists(storedRunPath) ? Read(storedRunPath) as JsonObject : null);
            await PersistOccurrencesAsync(analysis, analysis.CapturedPublication, runId, prepared, history);
            _overflowIssueUrl = await PublishOverflowAsync(aggregate);
            var runPath = history.RunPath(GetValueText(runId));
            var record = (JsonObject)Read(runPath)!;
            record["overflow_issue_url"] = _overflowIssueUrl;
            Write(runPath, record);
            await history.CommitAndPushAsync(GetValueText(runId), prepared.Select(item => item.Path).Append(runPath));
            await PublishCommentAsync(analysis);
            return;
        }
        var destinations = await ResolveDestinationsAsync(prepared);
        var evidence = analysis.CapturedPublication;
 
        // Persist occurrences before issue effects so retries can recover from partial publication.
        cancellationToken.ThrowIfCancellationRequested();
        await PersistOccurrencesAsync(analysis, evidence, runId, prepared, history);
        await PublishIssuesAsync(analysis, evidence, runId, prepared, destinations);
 
        // Save resolved issue links, then update the subject PR's analysis comment.
        await history.CommitAndPushAsync(GetValueText(runId), prepared.Select(p => p.Path));
        await PublishCommentAsync(analysis);
    }
 
    private List<PreparedCause> PrepareCauses(ValidatedAnalysis analysis, JsonNode runId, CauseHistoryStore store)
    {
        var prepared = new List<PreparedCause>();
        // Check every cause against fresh history before pushing conflicting identities.
        // Resolve every destination before persisting history or mutating any issue.
        foreach (var cause in analysis.Causes)
        {
            var path = store.CausePath(cause.FileName);
            var occurrence = CreateOccurrence(analysis, cause, runId, _observedAt);
            JsonObject? existing = null;
            var replayed = false;
            if (File.Exists(path))
            {
                existing = Read(path) as JsonObject ?? throw new ValidationException($"Stored cause is not readable: {cause.FileName}");
                if (!TryReadEnum<CauseType>(Text(existing["type"]), out var storedType))
                {
                    throw new ValidationException($"Stored cause type is not readable: {cause.FileName}");
                }
                var stored = ToContract<PriorCauseRecord>(existing) ?? throw new ValidationException($"Stored cause is not readable: {cause.FileName}");
                stored.RequireReadableCollections();
                var identity = new StoredCauseIdentity(storedType, stored.TestName) { Id = stored.Id };
                if (!(identity.Id == cause.Id))
                {
                    throw new ValidationException($"Stored cause ID must match its filename: {cause.FileName}");
                }
                RequireUnchangedCauseType(cause, identity);
                replayed = (existing["occurrences"] as JsonArray ?? []).Any(o => ReadNormalizedNumber(Field(o, "run_id")) == ReadNormalizedNumber(runId));
                if (!cause.IsExistingProposal && !replayed)
                {
                    throw new ValidationException($"New cause ID already exists in fresh history: {cause.FileName}");
                }
            }
            else if (cause.IsExistingProposal)
            {
                throw new ValidationException($"Existing cause is missing from fresh history: {cause.FileName}");
            }
            var definition = existing ?? (JsonObject?)ToNode(cause.Definition)
                ?? throw new ValidationException($"New cause is missing its definition: {cause.FileName}");
            var history = MergeCause(definition, existing, occurrence);
            prepared.Add(new PreparedCause(cause, history, path, replayed));
        }
 
        return prepared;
    }
 
    private async Task<Dictionary<string, JsonObject?>> ResolveDestinationsAsync(List<PreparedCause> prepared)
    {
        JsonArray? issueInventory = null;
        var destinations = new Dictionary<string, JsonObject?>(StringComparer.Ordinal);
        foreach (var (cause, history, _, _) in prepared)
        {
            JsonObject? issue = null;
            var linked = Text(history["issue_url"]);
            if (linked is not null && Regex.IsMatch(linked, $@"^https://github\.com/{Regex.Escape(_repository)}/issues/[1-9][0-9]*\z"))
            {
                _ = TransientJson.ParseId(linked.Split('/')[^1]);
                issue = await _github.GetAsync($"repos/{_repository}/issues/{linked.Split('/')[^1]}") as JsonObject;
                if (!(issue is not null && GetValueText(issue["number"]) == linked.Split('/')[^1] && MatchesIssue(issue, cause)))
                {
                    throw new ValidationException("Linked cause issue detail does not match the cause; review its destination before retrying");
                }
            }
            if (issue is null)
            {
                issueInventory ??= await LoadIssueInventoryAsync();
                issue = issueInventory.OfType<JsonObject>().Where(i => MatchesIssue(i, cause))
                    .OrderBy(i => Text(i["state"]) == "closed").FirstOrDefault();
                if (issue is not null)
                {
                    // Inventory identifies the issue, but only a fresh detail response
                    // can check its current disposition before history or issue writes.
                    var number = GitHubJson.ReadRequiredId(issue["number"]).ToString(System.Globalization.CultureInfo.InvariantCulture);
                    issue = await _github.GetAsync($"repos/{_repository}/issues/{number}") as JsonObject;
                    if (!(issue is not null && GetValueText(issue["number"]) == number && MatchesIssue(issue, cause)))
                    {
                        throw new ValidationException("Cause issue detail lookup returned invalid results");
                    }
                }
            }
            if (!(issue is null || GitHubJson.TryReadId(issue["number"], out _) && Text(issue["state"]) is "open" or "closed"))
            {
                throw new ValidationException("Cause issue detail lookup returned invalid results");
            }
            if (!(issue is null || GetValueText(issue["locked"]) != "true"))
            {
                throw new ValidationException($"Cause {cause.Id} destination is locked; review its destination before retrying");
            }
            if (!(issue is null || Text(issue["state"]) != "closed" || Text(issue["state_reason"]) != "duplicate"))
            {
                throw new ValidationException($"Cause {cause.Id} destination is closed as a duplicate; review its destination before retrying");
            }
            destinations.Add(cause.Id, issue);
        }
 
        return destinations;
    }
 
    private async Task PersistOccurrencesAsync(
        ValidatedAnalysis analysis,
        PublicationEvidence evidence,
        JsonNode runId,
        List<PreparedCause> prepared,
        CauseHistoryStore store)
    {
        store.CreateRecordDirectories();
        var runPath = store.RunPath(GetValueText(runId));
        var storedRun = File.Exists(runPath) ? Read(runPath) as JsonObject : null;
        var record = (JsonObject)ToNode(RunSummaryBuilder.Create(analysis.Report, analysis.Evidence, evidence, _observedAt))!;
        if (storedRun?["overflow_issue_url"] is { } overflow)
        {
            record["overflow_issue_url"] = overflow.DeepClone();
        }
        Write(runPath, record);
        foreach (var item in prepared)
        {
            Write(item.Path, item.History);
        }
        await store.CommitAndPushAsync(GetValueText(runId), prepared.Select(p => p.Path).Append(runPath));
    }
 
    private async Task PublishIssuesAsync(
        ValidatedAnalysis analysis,
        PublicationEvidence evidence,
        JsonNode runId,
        List<PreparedCause> prepared,
        Dictionary<string, JsonObject?> destinations)
    {
        foreach (var (cause, history, path, replayed) in prepared)
        {
            var issue = destinations[cause.Id];
            var (body, title, labels) = BuildIssueUpdate(analysis, evidence, cause, history, issue, runId, replayed);
            if (body is null)
            {
                _diagnostics.Receipt("issue-body", "skipped", new { cause = cause.Id, reason = "unsupported-section-or-budget" });
                Console.WriteLine($"::warning::Cause {cause.Id} has an unsupported managed section or exceeds the publication budget. Skipping body update.");
                if (issue is null)
                {
                    continue;
                }
            }
            var bodyPath = Path.Combine(_requestDirectory, $"{cause.Id}.issue-body.md");
            if (body is not null)
            {
                await File.WriteAllTextAsync(bodyPath, body);
            }
            var main = cause.Type == CauseType.MainRepositoryBreakage;
            if (main)
            {
                await UpsertMainLabelAsync(cause.Id);
            }
            if (issue is not null)
            {
                var number = GetValueText(issue["number"]);
                var request = new JsonObject();
                if (body is not null)
                {
                    request["body"] = body;
                }
                if (main)
                {
                    request["title"] = title;
                }
                if (body is not null || main)
                {
                    await ExecuteEffectAsync(
                        "issue-edit",
                        cause.Id,
                        $"https://github.com/{_repository}/issues/{number}",
                        HttpMethod.Patch,
                        $"repos/{_repository}/issues/{number}",
                        request,
                        HttpStatusCode.OK);
                }
                if (main)
                {
                    // PATCH labels replaces the complete set. The additive endpoint
                    // preserves operator-owned labels when updating a recurring cause.
                    // https://docs.github.com/en/rest/issues/labels#add-labels-to-an-issue
                    await ExecuteEffectAsync(
                        "issue-label-add",
                        cause.Id,
                        $"https://github.com/{_repository}/issues/{number}",
                        HttpMethod.Post,
                        $"repos/{_repository}/issues/{number}/labels",
                        new JsonObject { ["labels"] = new JsonArray(labels.Split(',').Select(label => JsonValue.Create(label)).ToArray()) },
                        HttpStatusCode.OK);
                }
                if (Text(issue["state"]) == "closed")
                {
                    await ExecuteEffectAsync(
                        "issue-reopen",
                        cause.Id,
                        $"https://github.com/{_repository}/issues/{number}",
                        HttpMethod.Patch,
                        $"repos/{_repository}/issues/{number}",
                        new JsonObject { ["state"] = "open" },
                        HttpStatusCode.OK);
                }
                history["issue_url"] = $"https://github.com/{_repository}/issues/{number}";
            }
            else
            {
                var url = await ExecuteEffectAsync(
                    "issue-create",
                    cause.Id,
                    destination: null,
                    HttpMethod.Post,
                    $"repos/{_repository}/issues",
                    new JsonObject
                    {
                        ["title"] = title,
                        ["body"] = body,
                        ["labels"] = new JsonArray(labels.Split(',').Select(label => JsonValue.Create(label)).ToArray()),
                    },
                    HttpStatusCode.Created);
                history["issue_url"] = url;
            }
            Write(path, history);
        }
    }
 
    private static CauseIssueRenderer.IssueDraft BuildIssueUpdate(
        ValidatedAnalysis analysis,
        PublicationEvidence evidence,
        FailureCause cause,
        JsonObject history,
        JsonObject? issue,
        JsonNode runId,
        bool replayed)
    {
        var occurrence = ((JsonArray)history["occurrences"]!).First(o => ReadNormalizedNumber(Field(o, "run_id")) == ReadNormalizedNumber(runId))!;
        var date = Text(occurrence["observed_at"])!.Split('T')[0];
        var jobs = cause.JobIds.Select(id => analysis.Jobs.First(job => job.Id == id).Name).ToArray();
        var displayJobs = CauseIssueRenderer.JobNames(jobs, "display");
        var tableJobs = CauseIssueRenderer.JobNames(jobs, "table");
        if (replayed)
        {
            // History stores an authenticated aggregate, not a reversible list of
            // job names. Do not infer its members from a later cause or analysis.
            var storedJob = DiagnosticTextSanitizer.Sanitize(Text(occurrence["job"]) ?? "");
            displayJobs = MarkdownFormatting.CodeSpan(storedJob);
            tableJobs = MarkdownFormatting.CodeSpan(storedJob.Replace("|", "\\|", StringComparison.Ordinal));
        }
        var pr = GetValueText(occurrence["pr_number"]);
        var scope = pr != "0" ? $"#{pr}" : analysis.Run.Scope == RunScope.Main ? "main" : "unavailable";
        var row = $"| {date} | [{GetValueText(runId)}]({Text(occurrence["run_url"])}) | {tableJobs} | {scope} |";
        var canonical = CauseIssueRenderer.RenderCanonicalIssue(
            history,
            analysis.Evidence.Context,
            evidence,
            analysis.Run.Url,
            EnumText(analysis.Run.Scope),
            pr,
            displayJobs,
            row);
        var body = canonical.Body!;
        if (issue is not null)
        {
            body = Text(issue["body"]) ?? "";
            try
            {
                if (!CauseIssueRenderer.HasOccurrence(body, GetValueText(runId)))
                {
                    body = CauseIssueRenderer.AppendOccurrence(body, row, ((JsonArray)history["occurrences"]!).Count);
                }
            }
            catch (ValidationException ex)
            {
                Console.WriteLine($"::warning::Cause {cause.Id} has an unsupported occurrence section. Keeping existing body: {Display(ex.Message)}");
                if (cause.Type != CauseType.MainRepositoryBreakage)
                {
                    return canonical with { Body = null };
                }
            }
            if (cause.Type == CauseType.MainRepositoryBreakage)
            {
                try
                {
                    body = CauseIssueRenderer.MigrateMainBody(body, canonical.Body!);
                }
                catch (ValidationException ex)
                {
                    Console.WriteLine($"::warning::Unable to migrate main issue body for {cause.Id}: {Display(ex.Message)}");
                }
            }
        }
 
        return canonical with { Body = CauseIssueRenderer.FitIssue(body, CauseIssueRenderer.Observations(history)) };
    }
 
    internal static JsonObject MergeCause(JsonObject definition, JsonObject? existing, JsonObject occurrence)
    {
        var history = (JsonObject)(existing ?? definition).DeepClone();
        history.Remove("job_ids");
        history.Remove("job_names");
        history.Remove("tests");
        // Preserve first-stored cause/run authority. Legacy rows have no attempt:
        // neither attempts nor a new replay/conflict policy are inferred here.
        var occurrences = ((history["occurrences"] as JsonArray ?? []).Append(occurrence))
            .DistinctBy(o => (ReadNormalizedNumber(Field(o, "run_id")), Text(Field(o, "run_id"))))
            .OrderBy(o => Text(Field(o, "observed_at")), StringComparer.Ordinal);
        history["occurrences"] = new JsonArray(occurrences.Select(o => o!.DeepClone()).ToArray());
 
        return history;
    }
 
    internal static JsonObject CreateOccurrence(ValidatedAnalysis analysis, FailureCause cause, JsonNode runId, string observedAt)
    {
        var jobNames = cause.JobIds.Select(id => analysis.Jobs.First(j => j.Id == id).Name);
        var occurrence = new CauseOccurrence
        {
            RunId = ToContract<System.Text.Json.JsonElement?>(runId),
            RunUrl = analysis.Run.Url,
            Job = CauseIssueRenderer.JobNames(jobNames, "plain"),
            ObservedAt = observedAt,
            PrNumber = ToContract<System.Text.Json.JsonElement?>(analysis.Run.Scope == RunScope.PullRequest &&
                PullRequestNumberRegex().IsMatch(analysis.Run.PrNumbers)
                ? JsonNode.Parse(analysis.Run.PrNumbers) : JsonValue.Create(value: 0)),
        };
        if (!cause.Tests.IsDefault)
        {
            var collectedTests = analysis.Tests;
            var tests = cause.Tests.Select(observation =>
            {
                var evidence = collectedTests.First(test => test.Name == observation.Name &&
                    AsNormalizedNumber(test.JobId) == observation.JobId &&
                    test.ObservationId == observation.ObservationId);
                // Evidence is rebound by validation. Copy diagnostics and provenance only
                // from the collector, never from the cause's agent-authored references.
                return new OccurrenceTest
                {
                    Name = evidence.DisplayName,
                    JobId = evidence.JobId,
                    Job = evidence.Job,
                    Error = evidence.Error,
                    StackTrace = evidence.StackTrace,
                    StandardOutput = evidence.StandardOutput,
                    StandardError = evidence.StandardError,
                    ResultFile = evidence.ResultFile,
                    ResultFormat = evidence.ResultFormat,
                    ObservationId = evidence.ObservationId,
                    ExecutionId = evidence.ExecutionId,
                };
            }).ToImmutableArray();
            occurrence = occurrence with { Tests = tests };
        }
 
        return (JsonObject)ToNode(occurrence)!;
    }
 
    private static bool MatchesIssue(JsonObject issue, FailureCause cause)
    {
        var lines = (Text(issue["body"]) ?? "").ReplaceLineEndings("\n").Split('\n');
        return issue["pull_request"] is null && issue["labels"] is JsonArray labels &&
            labels.Any(l => Text(Field(l, "name")) == "ci-failure-cause") &&
            lines.Length > 1 && lines[0] == $"<!-- ci-failure-cause:{cause.Id} -->" &&
            (lines[1] == $"<!-- ci-failure-cause-type:{EnumText(cause.Type)} -->" ||
                lines.Where(l => l.StartsWith("**Type**: ", StringComparison.Ordinal)).SequenceEqual([$"**Type**: {EnumText(cause.Type)}"]));
    }
 
    private async Task<JsonArray> LoadIssueInventoryAsync()
    {
        var (open, closed) = await new AnalysisPublicationQueries(_github).GetCauseIssuesAsync(_repository);
        return new JsonArray(open.Concat(closed).Select(issue => issue?.DeepClone()).ToArray());
    }
 
    private async Task PublishCommentAsync(ValidatedAnalysis analysis)
    {
        if (analysis.Run.Scope != RunScope.PullRequest || !PullRequestNumberRegex().IsMatch(analysis.Run.PrNumbers))
        {
            _diagnostics.Receipt("pr-comment", "skipped", new { reason = "no-subject-pr" });
            return;
        }
        var pr = analysis.Run.PrNumbers;
        var github = _github;
        var queries = new AnalysisPublicationQueries(github);
        string existing;
        try
        {
            if (!await queries.IsPullRequestOpenAndUnlockedAsync(_repository, pr))
            {
                _diagnostics.Receipt("pr-comment", "skipped", new { reason = "not-actionable", pr });
                return;
            }
            existing = await queries.FindAnalysisCommentIdAsync(_repository, pr);
        }
        catch (ValidationException ex)
        {
            _diagnostics.Receipt("pr-comment", "skipped", new { reason = "state-unknown", pr, error = ex.Message });
            Console.WriteLine($"::warning::Comment state is unknown. Skipping comment: {Display(ex.Message)}");
            return;
        }
        var comment = PrCommentRenderer.Render(analysis.Report, analysis.Evidence.Jobs, analysis.Run.Url);
        if (_overflowIssueUrl is not null)
        {
            comment += $"\n[Aggregate CI failure report]({_overflowIssueUrl})\n";
        }
        var file = Path.Combine(_requestDirectory, "analysis-comment.md");
        var request = Path.Combine(_requestDirectory, "analysis-comment-request.json");
        await File.WriteAllTextAsync(file, comment);
        Write(request, new JsonObject { ["body"] = comment });
        try
        {
            if (!await queries.IsPullRequestOpenAndUnlockedAsync(_repository, pr))
            {
                _diagnostics.Receipt("pr-comment", "skipped", new { reason = "no-longer-actionable", pr });
                return;
            }
        }
        catch (ValidationException ex)
        {
            _diagnostics.Receipt("pr-comment", "skipped", new { reason = "state-unknown", pr, error = ex.Message });
            Console.WriteLine($"::warning::PR state is unknown. Skipping comment: {Display(ex.Message)}");
            return;
        }
        if (existing.Length > 0)
        {
            await ExecuteEffectAsync(
                "pr-comment-edit",
                cause: null,
                $"https://github.com/{_repository}/pull/{pr}#issuecomment-{existing}",
                HttpMethod.Patch,
                $"repos/{_repository}/issues/comments/{existing}",
                new JsonObject { ["body"] = comment },
                HttpStatusCode.OK);
        }
        else
        {
            await ExecuteEffectAsync(
                "pr-comment-create",
                cause: null,
                $"https://github.com/{_repository}/pull/{pr}",
                HttpMethod.Post,
                $"repos/{_repository}/issues/{pr}/comments",
                new JsonObject { ["body"] = comment },
                HttpStatusCode.Created);
        }
    }
 
    private async Task UpsertMainLabelAsync(string cause)
    {
        var endpoint = $"repos/{_repository}/labels/main-ci-break";
        var exists = true;
        try
        {
            _ = await _github.GetAsync(endpoint);
        }
        catch (ValidationException error) when (error.InnerException is GitHubApiException { StatusCode: HttpStatusCode.NotFound })
        {
            exists = false;
        }
        await ExecuteEffectAsync(
            "label-upsert",
            cause,
            destination: null,
            exists ? HttpMethod.Patch : HttpMethod.Post,
            exists ? endpoint : $"repos/{_repository}/labels",
            new JsonObject
            {
                ["name"] = "main-ci-break",
                ["color"] = "b60205",
                ["description"] = "Deterministic repository breakage on the main branch",
            },
            exists ? HttpStatusCode.OK : HttpStatusCode.Created);
    }
 
    private async Task<string> ExecuteEffectAsync(
        string action,
        string? cause,
        string? destination,
        HttpMethod method,
        string endpoint,
        JsonNode? request,
        HttpStatusCode expectedStatus)
    {
        try
        {
            var response = await _github.SendJsonAsync(method, endpoint, request, expectedStatus);
            var createsComment = action is "pr-comment-create" or "overflow-comment-create";
            var returnsCreatedUrl = action == "issue-create" || createsComment;
            var result = "";
            if (returnsCreatedUrl)
            {
                result = Text(Field(response, "html_url")) ?? "";
                var idField = createsComment ? "id" : "number";
                var validId = GitHubJson.TryReadId(Field(response, idField), out var id);
                var pattern = createsComment
                    ? "^" + Regex.Escape(destination!).Replace("/pull/", "/(?:pull|issues)/", StringComparison.Ordinal) +
                        "#issuecomment-" + id.ToString(System.Globalization.CultureInfo.InvariantCulture) + @"\z"
                    : "^" + Regex.Escape($"https://github.com/{_repository}/issues/{id.ToString(System.Globalization.CultureInfo.InvariantCulture)}") + @"\z";
                if (!validId || !Regex.IsMatch(result, pattern))
                {
                    throw new ValidationException(createsComment
                        ? "Comment creation did not return a canonical comment URL"
                        : "Issue creation did not return a canonical issue URL");
                }
            }
            _diagnostics.Receipt(action, "confirmed", new { cause, url = returnsCreatedUrl ? result : destination });
            return result;
        }
        catch (Exception error)
        {
            // A failed response or cancellation cannot establish whether the remote accepted
            // the mutation. Receipts describe observations, never authorize retries or effects.
            _diagnostics.Receipt(action, "unknown", new { cause, url = destination, reason = error.Message });
            throw;
        }
    }
 
    /// <summary>Holds a preflighted cause, its merged history, storage destination and occurrence replay state.</summary>
    private sealed record PreparedCause(FailureCause Cause, JsonObject History, string Path, bool Replayed);
 
    [GeneratedRegex(@"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z")]
    private static partial Regex GitHubRepositoryNameRegex();
 
    [GeneratedRegex(@"^[0-9]+\z")]
    private static partial Regex PullRequestNumberRegex();
}