Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ namespace PrStagingDeploy.Services;
using System.Text;
using PrStagingDeploy.Models;

/// <summary>Per-service outcome for a partial docs/samples deploy (states: skipped, pending, deployed, failed).</summary>
public record StagingCommentPartialBreakdown(
string DocsState,
string SamplesState,
string? DocsUrl,
string? SamplesUrl);

/// <summary>
/// Posts a single PR comment with staging links using <c>GitHub:PrCommentToken</c> (PAT).
/// Each post removes every prior marker comment and creates a new one so the thread always shows one fresh bot message.
Expand Down Expand Up @@ -48,13 +55,14 @@ public async Task TryPostOrUpdateStagingCommentAsync(
string? samplesUrl,
string? status = null,
IReadOnlyList<string>? 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);
Expand Down Expand Up @@ -95,6 +103,7 @@ public Task TryNotifyDeployQueuedAsync(
samplesUrl: null,
status: progressStatus,
logLines: null,
partial: null,
cancellationToken);
}

Expand Down Expand Up @@ -142,8 +151,12 @@ private static string BuildCommentBody(
string? docsUrl,
string? samplesUrl,
string? status,
IReadOnlyList<string>? logLines)
IReadOnlyList<string>? logLines,
StagingCommentPartialBreakdown? partial = null)
{
if (partial != null)
return BuildPartialDeploymentBody(logLines, partial);

var statusText = string.IsNullOrWhiteSpace(status)
? "Staging preview"
: status.Trim();
Expand Down Expand Up @@ -189,20 +202,58 @@ private static string BuildCommentBody(
}
}

if (logLines is { Count: > 0 })
AppendLogSection(sb, logLines);
return sb.ToString();
}

private static string BuildPartialDeploymentBody(
IReadOnlyList<string>? 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<string>? 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -141,63 +159,56 @@ 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));
}
// App shutting down — do nothing, startup scan will resume on next deploy.
}

/// <summary>
/// Events often lag behind; listing usually shows <c>live</c> sooner. Prefer failure if either source reports it.
/// </summary>
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;
}

/// <summary>
/// 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".
/// </summary>
private static string ResolveStatusFromListing(StagingDeployment? dep, string? docsServiceId, string? samplesServiceId)
private static string MergeServiceWithListing(string? serviceId, List<SliplaneServiceEvent> events, string? listingField)
{
if (dep == null) return "pending";

var statuses = new List<string?>();
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<string?> { 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<string> BuildLogLinesForFailedServicesOnly(
string docsMerged,
string samplesMerged,
List<SliplaneServiceEvent> docsEvents,
List<SliplaneServiceEvent> 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<SliplaneServiceEvent>(), maxLines);

// All live → deployed.
if (relevant.All(s => s == "live"))
return "deployed";
if (samplesMerged == "failed")
return BuildRecentLogLines(new List<SliplaneServiceEvent>(), samplesEvents, maxLines);

// Still building / deploying / starting → pending.
return "pending";
return Array.Empty<string>();
}

private static IReadOnlyList<string> BuildRecentLogLines(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,63 @@ public static string ResolveCombinedStatus(
List<SliplaneServiceEvent> docsEvents,
string? samplesServiceId,
List<SliplaneServiceEvent> samplesEvents)
{
var docs = string.IsNullOrEmpty(docsServiceId) ? "skipped" : GetStatusFromEvents(docsEvents);
var samples = string.IsNullOrEmpty(samplesServiceId) ? "skipped" : GetStatusFromEvents(samplesEvents);
return ResolveOverallTerminal(docs, samples);
}

/// <summary>Per-service state from the events API: <c>pending</c>, <c>deployed</c>, or <c>failed</c>.</summary>
public static string ResolveStatusFromEvents(List<SliplaneServiceEvent> events) => GetStatusFromEvents(events);

/// <summary>Maps Sliplane listing <c>status</c> field to pending/deployed/failed.</summary>
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";
}

/// <summary>Merge events + listing for one service (same rules as the background worker used for combined).</summary>
public static string MergeServiceState(string fromEvents, string fromListing)
{
if (fromEvents == "failed" || fromListing == "failed")
return "failed";
if (fromEvents == "deployed" || fromListing == "deployed")
return "deployed";
return "pending";
}

/// <param name="docs">skipped | pending | deployed | failed</param>
/// <param name="samples">skipped | pending | deployed | failed</param>
/// <returns>pending | deployed | failed | partial</returns>
public static string ResolveOverallTerminal(string docs, string samples)
{
var parts = new List<string>();
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";
}

Expand Down
Loading