From de20255ca568f951117a6d73a63d11ae371ec219 Mon Sep 17 00:00:00 2001 From: Artem Lazarchuk Date: Fri, 3 Apr 2026 11:38:19 +0300 Subject: [PATCH] feat: Introduce partial deployment handling in PrStagingDeployCommentService and update background service for improved status resolution. Add StagingCommentPartialBreakdown record to encapsulate deployment states and URLs. Enhance comment body construction to support partial deployment scenarios, ensuring accurate status reporting for both docs and samples. (#421) --- .../Services/PrStagingDeployCommentService.cs | 79 +++++++++--- ...ingDeployCommentUpdateBackgroundService.cs | 113 ++++++++++-------- .../SliplaneDeploymentStatusResolver.cs | 57 +++++++-- 3 files changed, 175 insertions(+), 74 deletions(-) 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"; }