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";
}