diff --git a/project-demos/pr-staging-deploy/Services/PrStagingDeployCommentService.cs b/project-demos/pr-staging-deploy/Services/PrStagingDeployCommentService.cs index ca85083b..613c009e 100644 --- a/project-demos/pr-staging-deploy/Services/PrStagingDeployCommentService.cs +++ b/project-demos/pr-staging-deploy/Services/PrStagingDeployCommentService.cs @@ -3,6 +3,13 @@ namespace PrStagingDeploy.Services; using System.Text; using PrStagingDeploy.Models; +/// Per-service outcome for a partial docs/samples deploy (states: skipped, pending, deployed, failed). +public record StagingCommentPartialBreakdown( + string DocsState, + string SamplesState, + string? DocsUrl, + string? SamplesUrl); + /// /// Posts a single PR comment with staging links using GitHub:PrCommentToken (PAT). /// Each post removes every prior marker comment and creates a new one so the thread always shows one fresh bot message. @@ -48,13 +55,14 @@ public async Task TryPostOrUpdateStagingCommentAsync( string? samplesUrl, string? status = null, IReadOnlyList? logLines = null, + StagingCommentPartialBreakdown? partial = null, CancellationToken cancellationToken = default) { var pat = _config["GitHub:PrCommentToken"] ?? ""; if (string.IsNullOrWhiteSpace(pat)) return; - var body = BuildCommentBody(docsUrl, samplesUrl, status, logLines); + var body = BuildCommentBody(docsUrl, samplesUrl, status, logLines, partial); await DeleteAllMarkerCommentsAsync(owner, repo, prNumber, pat, cancellationToken); var id = await _github.CreateIssueCommentAsync(owner, repo, prNumber, pat, body, cancellationToken); @@ -95,6 +103,7 @@ public Task TryNotifyDeployQueuedAsync( samplesUrl: null, status: progressStatus, logLines: null, + partial: null, cancellationToken); } @@ -142,8 +151,12 @@ private static string BuildCommentBody( string? docsUrl, string? samplesUrl, string? status, - IReadOnlyList? logLines) + IReadOnlyList? logLines, + StagingCommentPartialBreakdown? partial = null) { + if (partial != null) + return BuildPartialDeploymentBody(logLines, partial); + var statusText = string.IsNullOrWhiteSpace(status) ? "Staging preview" : status.Trim(); @@ -189,20 +202,58 @@ private static string BuildCommentBody( } } - if (logLines is { Count: > 0 }) + AppendLogSection(sb, logLines); + return sb.ToString(); + } + + private static string BuildPartialDeploymentBody( + IReadOnlyList? logLines, + StagingCommentPartialBreakdown p) + { + var sb = new StringBuilder(); + sb.AppendLine(Marker); + sb.AppendLine(); + sb.AppendLine("### Partial deployment"); + sb.AppendLine(); + sb.AppendLine(FormatPartialServiceLine("Docs", p.DocsState, p.DocsUrl)); + sb.AppendLine(FormatPartialServiceLine("Samples", p.SamplesState, p.SamplesUrl)); + sb.AppendLine(); + sb.AppendLine("Docs and samples are separate services: one succeeded and the other failed or was not created. Check Sliplane for full logs."); + AppendLogSection(sb, logLines); + return sb.ToString(); + } + + private static string FormatPartialServiceLine(string label, string state, string? url) + { + return state switch { - sb.AppendLine(); - sb.AppendLine("### Logs"); - sb.AppendLine(); - sb.AppendLine("```"); - foreach (var line in logLines) - { - if (string.IsNullOrWhiteSpace(line)) continue; - sb.AppendLine(line); - } - sb.AppendLine("```"); + "deployed" when !string.IsNullOrWhiteSpace(url) => + $"**{label}:** OK — [{url}]({url})", + "deployed" => + $"**{label}:** OK (live)", + "failed" => + $"**{label}:** Failed (build or deploy)", + "skipped" => + $"**{label}:** Not provisioned (no Sliplane service for this PR)", + _ => + $"**{label}:** In progress…" + }; + } + + private static void AppendLogSection(StringBuilder sb, IReadOnlyList? logLines) + { + if (logLines is not { Count: > 0 }) + return; + sb.AppendLine(); + sb.AppendLine("### Logs"); + sb.AppendLine(); + sb.AppendLine("```"); + foreach (var line in logLines) + { + if (string.IsNullOrWhiteSpace(line)) continue; + sb.AppendLine(line); } - return sb.ToString(); + sb.AppendLine("```"); } private static string FormatLinkLine(string label, string? url) diff --git a/project-demos/pr-staging-deploy/Services/PrStagingDeployCommentUpdateBackgroundService.cs b/project-demos/pr-staging-deploy/Services/PrStagingDeployCommentUpdateBackgroundService.cs index f9a9579c..49f18243 100644 --- a/project-demos/pr-staging-deploy/Services/PrStagingDeployCommentUpdateBackgroundService.cs +++ b/project-demos/pr-staging-deploy/Services/PrStagingDeployCommentUpdateBackgroundService.cs @@ -97,20 +97,36 @@ await _commentService.TryPostOrUpdateStagingCommentAsync( var (docsEvents, samplesEvents) = await _deployService.GetDeploymentEventsForServicesAsync( apiToken, docsServiceId, samplesServiceId); - var combinedFromEvents = SliplaneDeploymentStatusResolver.ResolveCombinedStatus( - docsServiceId, docsEvents, samplesServiceId, samplesEvents); - var dep = await _deployService.GetDeploymentByPrNumberAsync(apiToken, req.PrNumber); - var combinedFromListing = ResolveStatusFromListing(dep, docsServiceId, samplesServiceId); - var combined = MergeEventAndListingStatus(combinedFromEvents, combinedFromListing); + var docsMerged = MergeServiceWithListing( + docsServiceId, docsEvents, GetListingFieldForService(dep, docsServiceId, docs: true)); + var samplesMerged = MergeServiceWithListing( + samplesServiceId, samplesEvents, GetListingFieldForService(dep, samplesServiceId, docs: false)); + var overall = SliplaneDeploymentStatusResolver.ResolveOverallTerminal(docsMerged, samplesMerged); _logger.LogDebug( - "PR #{Pr} deploy status: merged={Merged} (events={Events}, listing={Listing}; docsEv={DocsEv}, samplesEv={SamplesEv})", - req.PrNumber, combined, combinedFromEvents, combinedFromListing, docsEvents.Count, samplesEvents.Count); + "PR #{Pr} deploy status: overall={Overall} (docs={Docs}, samples={Samples}; docsEv={DocsEv}, samplesEv={SamplesEv})", + req.PrNumber, overall, docsMerged, samplesMerged, docsEvents.Count, samplesEvents.Count); var urls = await _deployService.GetDeploymentUrlsForPrAsync(apiToken, req.PrNumber); - if (combined == "failed") + if (overall == "partial") + { + var logLines = BuildLogLinesForFailedServicesOnly( + docsMerged, samplesMerged, docsEvents, samplesEvents, maxLines: 12); + var breakdown = new StagingCommentPartialBreakdown( + docsMerged, samplesMerged, urls.DocsUrl, urls.SamplesUrl); + await _commentService.TryPostOrUpdateStagingCommentAsync( + req.Owner, req.Repo, req.PrNumber, + docsUrl: urls.DocsUrl, samplesUrl: urls.SamplesUrl, + status: null, + logLines: logLines, + partial: breakdown, + cancellationToken: CancellationToken.None); + return; + } + + if (overall == "failed") { var logLines = BuildRecentLogLines(docsEvents, samplesEvents, maxLines: 10); await _commentService.TryPostOrUpdateStagingCommentAsync( @@ -122,16 +138,18 @@ await _commentService.TryPostOrUpdateStagingCommentAsync( return; } - if (combined == "deployed") + if (overall == "deployed") { - // Track when "deployed" was first seen. deployedAt ??= DateTime.UtcNow; - var bothUrlsReady = !string.IsNullOrWhiteSpace(urls.DocsUrl) && !string.IsNullOrWhiteSpace(urls.SamplesUrl); + var docsWantsUrl = !string.IsNullOrEmpty(docsServiceId); + var samplesWantsUrl = !string.IsNullOrEmpty(samplesServiceId); + var urlsReady = + (!docsWantsUrl || !string.IsNullOrWhiteSpace(urls.DocsUrl)) + && (!samplesWantsUrl || !string.IsNullOrWhiteSpace(urls.SamplesUrl)); var urlWaitExpired = (DateTime.UtcNow - deployedAt.Value).TotalSeconds >= 30; - // Post as soon as both URLs are available, or after 30s if they never appear. - if (bothUrlsReady || urlWaitExpired) + if (urlsReady || urlWaitExpired) { await _commentService.TryPostOrUpdateStagingCommentAsync( req.Owner, req.Repo, req.PrNumber, @@ -141,12 +159,12 @@ await _commentService.TryPostOrUpdateStagingCommentAsync( cancellationToken: CancellationToken.None); return; } - // URLs not ready yet — keep polling with short delay. + await Task.Delay(2500, ct); continue; } - // combined == "pending": poll silently, no intermediate comment + // overall == "pending": poll silently, no intermediate comment await Task.Delay(delayMs, ct); if (delayMs < maxDelayMs) delayMs = Math.Min(maxDelayMs, (int)(delayMs * 1.25)); @@ -154,50 +172,43 @@ await _commentService.TryPostOrUpdateStagingCommentAsync( // App shutting down — do nothing, startup scan will resume on next deploy. } - /// - /// Events often lag behind; listing usually shows live sooner. Prefer failure if either source reports it. - /// - private static string MergeEventAndListingStatus(string fromEvents, string fromListing) + private static string? GetListingFieldForService(StagingDeployment? dep, string? serviceId, bool docs) { - if (fromEvents == "failed" || fromListing == "failed") - return "failed"; - if (fromEvents == "deployed" || fromListing == "deployed") - return "deployed"; - return "pending"; + if (dep == null || string.IsNullOrEmpty(serviceId)) + return null; + if (docs) + return dep.DocsServiceId == serviceId ? dep.DocsStatus : null; + return dep.SamplesServiceId == serviceId ? dep.SamplesStatus : null; } - /// - /// Determines deploy status from the Sliplane listing's status field. - /// Used as a fallback when the events API returns no data. - /// Sliplane status values include: "live", "deploying", "building", "error", "dead", "starting". - /// - private static string ResolveStatusFromListing(StagingDeployment? dep, string? docsServiceId, string? samplesServiceId) + private static string MergeServiceWithListing(string? serviceId, List events, string? listingField) { - if (dep == null) return "pending"; - - var statuses = new List(); - if (!string.IsNullOrEmpty(docsServiceId) && dep.DocsServiceId == docsServiceId) - statuses.Add(dep.DocsStatus); - if (!string.IsNullOrEmpty(samplesServiceId) && dep.SamplesServiceId == samplesServiceId) - statuses.Add(dep.SamplesStatus); - - // If we couldn't match the specific service IDs, use whatever status is available. - if (statuses.Count == 0) - statuses = new List { dep.DocsStatus, dep.SamplesStatus }; + if (string.IsNullOrEmpty(serviceId)) + return "skipped"; + var fromEvents = SliplaneDeploymentStatusResolver.ResolveStatusFromEvents(events); + var fromListing = string.IsNullOrEmpty(listingField) + ? "pending" + : SliplaneDeploymentStatusResolver.ListingFieldToState(listingField); + return SliplaneDeploymentStatusResolver.MergeServiceState(fromEvents, fromListing); + } - var relevant = statuses.Where(s => !string.IsNullOrEmpty(s)).ToList(); - if (relevant.Count == 0) return "pending"; + private static IReadOnlyList BuildLogLinesForFailedServicesOnly( + string docsMerged, + string samplesMerged, + List docsEvents, + List samplesEvents, + int maxLines) + { + if (docsMerged == "failed" && samplesMerged == "failed") + return BuildRecentLogLines(docsEvents, samplesEvents, maxLines); - // Any terminal-failure state → report failed. - if (relevant.Any(s => s is "error" or "dead" or "crashed" or "unhealthy")) - return "failed"; + if (docsMerged == "failed") + return BuildRecentLogLines(docsEvents, new List(), maxLines); - // All live → deployed. - if (relevant.All(s => s == "live")) - return "deployed"; + if (samplesMerged == "failed") + return BuildRecentLogLines(new List(), samplesEvents, maxLines); - // Still building / deploying / starting → pending. - return "pending"; + return Array.Empty(); } private static IReadOnlyList BuildRecentLogLines( diff --git a/project-demos/pr-staging-deploy/Services/SliplaneDeploymentStatusResolver.cs b/project-demos/pr-staging-deploy/Services/SliplaneDeploymentStatusResolver.cs index cc55692a..739fd3ad 100644 --- a/project-demos/pr-staging-deploy/Services/SliplaneDeploymentStatusResolver.cs +++ b/project-demos/pr-staging-deploy/Services/SliplaneDeploymentStatusResolver.cs @@ -13,24 +13,63 @@ public static string ResolveCombinedStatus( List docsEvents, string? samplesServiceId, List samplesEvents) + { + var docs = string.IsNullOrEmpty(docsServiceId) ? "skipped" : GetStatusFromEvents(docsEvents); + var samples = string.IsNullOrEmpty(samplesServiceId) ? "skipped" : GetStatusFromEvents(samplesEvents); + return ResolveOverallTerminal(docs, samples); + } + + /// Per-service state from the events API: pending, deployed, or failed. + public static string ResolveStatusFromEvents(List events) => GetStatusFromEvents(events); + + /// Maps Sliplane listing status field to pending/deployed/failed. + public static string ListingFieldToState(string? listingStatus) + { + if (string.IsNullOrWhiteSpace(listingStatus)) + return "pending"; + var s = listingStatus.ToLowerInvariant(); + if (s is "error" or "dead" or "crashed" or "unhealthy") + return "failed"; + if (s == "live") + return "deployed"; + return "pending"; + } + + /// Merge events + listing for one service (same rules as the background worker used for combined). + public static string MergeServiceState(string fromEvents, string fromListing) + { + if (fromEvents == "failed" || fromListing == "failed") + return "failed"; + if (fromEvents == "deployed" || fromListing == "deployed") + return "deployed"; + return "pending"; + } + + /// skipped | pending | deployed | failed + /// skipped | pending | deployed | failed + /// pending | deployed | failed | partial + public static string ResolveOverallTerminal(string docs, string samples) { var parts = new List(); - if (!string.IsNullOrEmpty(docsServiceId)) - parts.Add(GetStatusFromEvents(docsEvents)); - if (!string.IsNullOrEmpty(samplesServiceId)) - parts.Add(GetStatusFromEvents(samplesEvents)); + if (docs != "skipped") + parts.Add(docs); + if (samples != "skipped") + parts.Add(samples); if (parts.Count == 0) return "pending"; - - // If anything is still pending — keep waiting. Don't stop just because one failed. if (parts.Exists(p => p == "pending")) return "pending"; - - // All services resolved: report failed if any failed, otherwise deployed. + if (parts.Exists(p => p == "deployed") && parts.Exists(p => p == "failed")) + return "partial"; + if (parts.Exists(p => p == "deployed") && parts.Exists(p => p == "skipped")) + return "partial"; + if (parts.All(p => p == "deployed")) + return "deployed"; + if (parts.Exists(p => p == "failed") && parts.Exists(p => p == "skipped")) + return "partial"; if (parts.Exists(p => p == "failed")) return "failed"; - return "deployed"; }