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
7 changes: 3 additions & 4 deletions project-demos/pr-staging-deploy/Apps/PrStagingDeployApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,7 @@ private record PrRow(
var rows = new List<PrRow>();
foreach (var pr in prs)
{
var branchSafe = SliplaneStagingClient.SanitizeBranchName(pr.HeadRef);
var dep = deployments.FirstOrDefault(d => d.BranchSafe == branchSafe);
var dep = deployments.FirstOrDefault(d => d.BranchSafe == pr.Number.ToString());

string status;
Icons statusIcon;
Expand Down Expand Up @@ -304,7 +303,7 @@ await prComments.TryPostOrUpdateStagingCommentAsync(
forceNewComment: true);
}

var result = await deploySvc.DeployBranchAsync(t, branchName);
var result = await deploySvc.DeployBranchAsync(t, branchName, prNumber);
ShowMessage(result.Message, !result.Success);

// Once Sliplane has registered the service (or failed), remove from deployingBranches.
Expand Down Expand Up @@ -389,7 +388,7 @@ await prComments.TryPostOrUpdateStagingCommentAsync(
logLines: null);
}

var result = await deploySvc.DeleteBranchAsync(t, branchName);
var result = await deploySvc.DeleteBranchAsync(t, prNumber);
ShowMessage(result.Message, !result.Success);
if (result.Success)
{
Expand Down
87 changes: 53 additions & 34 deletions project-demos/pr-staging-deploy/Services/GitHubWebhookHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ private async Task HandlePullRequestAsync(JsonElement root)
var owner = repoEl.GetProperty("owner").GetProperty("login").GetString() ?? "";
var repoName = repoEl.GetProperty("name").GetString() ?? "";

// For fork PRs the head repo differs from the base repo.
// Sliplane must clone from the fork URL, otherwise the branch won't be found.
var headRepoEl = pr.GetProperty("head").GetProperty("repo");
var headRepoCloneUrl = headRepoEl.TryGetProperty("clone_url", out var cu)
? cu.GetString()
: null;

var apiToken = GetApiToken();
if (string.IsNullOrEmpty(apiToken))
{
Expand All @@ -109,8 +116,8 @@ private async Task HandlePullRequestAsync(JsonElement root)
break;
}

// Check if staging services already exist for this branch.
var existingDepOnOpen = await _deployService.GetDeploymentByBranchAsync(apiToken, branch);
// Check if staging services already exist for this PR.
var existingDepOnOpen = await _deployService.GetDeploymentByPrNumberAsync(apiToken, prNumber);
if (existingDepOnOpen != null)
{
_logger.LogInformation(
Expand Down Expand Up @@ -169,7 +176,7 @@ await _prComments.TryPostOrUpdateStagingCommentAsync(
forceNewComment: true);

_logger.LogInformation("PR #{Pr} opened: {Title} branch={Branch}", prNumber, title, branch);
var deployResult = await _deployService.DeployBranchAsync(apiToken, branch);
var deployResult = await _deployService.DeployBranchAsync(apiToken, branch, prNumber, headRepoCloneUrl);
_logger.LogInformation("Deploy result: {Success} - {Message}", deployResult.Success, deployResult.Message);
if (deployResult.Success)
{
Expand Down Expand Up @@ -228,50 +235,62 @@ await _prComments.TryPostOrUpdateStagingCommentAsync(
forceNewComment: true);

_logger.LogInformation("PR #{Pr} updated: {Branch}", prNumber, branch);
var redeployResult = await _deployService.RedeployBranchAsync(apiToken, branch);
var redeployResult = await _deployService.RedeployBranchAsync(apiToken, branch, prNumber);
_logger.LogInformation("Redeploy result: {Success} - {Message}", redeployResult.Success, redeployResult.Message);
if (redeployResult.Success)

if (!redeployResult.Success)
{
var dep = await _deployService.GetDeploymentByBranchAsync(apiToken, branch);
// Services don't exist yet (e.g. first push, or staging was deleted).
// Fall back to a fresh deploy instead of reporting a failure.
_logger.LogInformation("PR #{Pr} redeploy found 0 services, falling back to fresh deploy", prNumber);
var fallbackResult = await _deployService.DeployBranchAsync(apiToken, branch, prNumber, headRepoCloneUrl);
_logger.LogInformation("Fallback deploy result: {Success} - {Message}", fallbackResult.Success, fallbackResult.Message);

if (dep is null || string.IsNullOrEmpty(dep.DocsServiceId) || string.IsNullOrEmpty(dep.SamplesServiceId))
if (fallbackResult.Success && (!string.IsNullOrEmpty(fallbackResult.DocsServiceId) || !string.IsNullOrEmpty(fallbackResult.SamplesServiceId)))
{
await _commentUpdateQueue.EnqueueAsync(new PrStagingDeployCommentUpdateRequest(
Owner: owner,
Repo: repoName,
PrNumber: prNumber,
BranchName: branch,
DocsServiceId: fallbackResult.DocsServiceId,
SamplesServiceId: fallbackResult.SamplesServiceId));
}
else
{
await _prComments.TryPostOrUpdateStagingCommentAsync(
owner,
repoName,
prNumber,
docsUrl: null,
samplesUrl: null,
owner, repoName, prNumber,
docsUrl: null, samplesUrl: null,
status: "Deploy failed",
logLines: new[] { "Redeploy: docs/samples services not found in Sliplane." });
break;
logLines: new[] { TruncLine(fallbackResult.Message, 240) });
}

await _commentUpdateQueue.EnqueueAsync(new PrStagingDeployCommentUpdateRequest(
Owner: owner,
Repo: repoName,
PrNumber: prNumber,
BranchName: branch,
DocsServiceId: dep.DocsServiceId,
SamplesServiceId: dep.SamplesServiceId));
break;
}
else

var syncDep = await _deployService.GetDeploymentByPrNumberAsync(apiToken, prNumber);
if (syncDep is null || string.IsNullOrEmpty(syncDep.DocsServiceId) || string.IsNullOrEmpty(syncDep.SamplesServiceId))
{
await _prComments.TryPostOrUpdateStagingCommentAsync(
owner,
repoName,
prNumber,
docsUrl: null,
samplesUrl: null,
status: "Redeploy failed",
logLines: new[] { TruncLine(redeployResult.Message, 240) });
owner, repoName, prNumber,
docsUrl: null, samplesUrl: null,
status: "Deploy failed",
logLines: new[] { "Redeploy: docs/samples services not found in Sliplane." });
break;
}

await _commentUpdateQueue.EnqueueAsync(new PrStagingDeployCommentUpdateRequest(
Owner: owner,
Repo: repoName,
PrNumber: prNumber,
BranchName: branch,
DocsServiceId: syncDep.DocsServiceId,
SamplesServiceId: syncDep.SamplesServiceId));

break;

case "closed":
_logger.LogInformation("PR #{Pr} closed: {Branch} — removing Sliplane staging services", prNumber, branch);
var deleteResult = await _deployService.DeleteBranchAsync(apiToken, branch);
var deleteResult = await _deployService.DeleteBranchAsync(apiToken, prNumber);
_logger.LogInformation("Delete result: {Success} - {Message}", deleteResult.Success, deleteResult.Message);
if (deleteResult.Success)
await _prComments.TryPostOrUpdateStagingCommentAsync(
Expand Down Expand Up @@ -338,8 +357,8 @@ private async Task HandleIssueCommentAsync(JsonElement root)
// Let users know the bot read the `/deploy` command.
await _prComments.TryAddRocketReactionAsync(owner, repo, commentId);

// Check if services already exist for this branch — avoid creating duplicates.
var existingDep = await _deployService.GetDeploymentByBranchAsync(apiToken, branch);
// Check if services already exist for this PR — avoid creating duplicates.
var existingDep = await _deployService.GetDeploymentByPrNumberAsync(apiToken, prNumber);
if (existingDep != null)
{
_logger.LogInformation(
Expand Down Expand Up @@ -400,7 +419,7 @@ await _prComments.TryPostOrUpdateStagingCommentAsync(
forceNewComment: true);

_logger.LogInformation("PR #{Pr} /deploy comment: {Branch}", prNumber, branch);
var result = await _deployService.DeployBranchAsync(apiToken, branch);
var result = await _deployService.DeployBranchAsync(apiToken, branch, prNumber);
_logger.LogInformation("Deploy result: {Success} - {Message}", result.Success, result.Message);
if (result.Success)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ await _commentService.TryPostOrUpdateStagingCommentAsync(
samplesServiceId, samplesEvents);

// Fetch URLs on each iteration so we can show partial links as they appear.
var urls = await _deployService.GetDeploymentUrlsForBranchAsync(apiToken, req.BranchName);
var urls = await _deployService.GetDeploymentUrlsForPrAsync(apiToken, req.PrNumber);
var hasAnyUrl = !string.IsNullOrWhiteSpace(urls.DocsUrl) || !string.IsNullOrWhiteSpace(urls.SamplesUrl);

// Check if any individual service already has an error event
Expand Down
45 changes: 22 additions & 23 deletions project-demos/pr-staging-deploy/Services/StagingDeployService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,18 @@ public StagingDeployService(SliplaneStagingClient sliplane, GitHubApiClient gith
public string DocsDockerfile => _config["Staging:DocsDockerfile"] ?? "Dockerfile";
public int ExpiryDays => int.TryParse(_config["Staging:ExpiryDays"], out var d) ? d : 7;

public async Task<StagingDeployResult> DeployBranchAsync(string apiToken, string branchName)
public async Task<StagingDeployResult> DeployBranchAsync(string apiToken, string branchName, int prNumber, string? samplesRepoOverride = null)
{
var projectId = _config["Sliplane:ProjectId"] ?? "";
var serverId = _config["Sliplane:ServerId"] ?? "";
if (string.IsNullOrEmpty(projectId) || string.IsNullOrEmpty(serverId))
return new StagingDeployResult(false, "Sliplane:ProjectId and ServerId required.");

var safe = SliplaneStagingClient.SanitizeBranchName(branchName);
var docsName = $"ivy-staging-docs-{safe}";
var samplesName = $"ivy-staging-samples-{safe}";
var docsName = $"ivy-staging-docs-{prNumber}";
var samplesName = $"ivy-staging-samples-{prNumber}";

// For fork PRs use the fork's clone URL so Sliplane can find the branch.
var samplesRepo = !string.IsNullOrEmpty(samplesRepoOverride) ? samplesRepoOverride : SamplesRepo;

string? docsUrl = null;
string? samplesUrl = null;
Expand All @@ -55,7 +57,7 @@ public async Task<StagingDeployResult> DeployBranchAsync(string apiToken, string

var samplesResult = await _sliplane.CreateServiceAsync(
apiToken, projectId, serverId,
samplesName, SamplesRepo, branchName, SamplesDockerfile, SamplesContext);
samplesName, samplesRepo, branchName, SamplesDockerfile, SamplesContext);
if (samplesResult.Service != null)
{
samplesId = samplesResult.Service.Id;
Expand All @@ -76,13 +78,12 @@ public async Task<StagingDeployResult> DeployBranchAsync(string apiToken, string
}
}

public async Task<StagingDeployResult> RedeployBranchAsync(string apiToken, string branchName)
public async Task<StagingDeployResult> RedeployBranchAsync(string apiToken, string branchName, int prNumber)
{
var projectId = _config["Sliplane:ProjectId"] ?? "";
var services = await _sliplane.ListStagingServicesAsync(apiToken, projectId, "ivy-staging-");
var safe = SliplaneStagingClient.SanitizeBranchName(branchName);
var docsSvc = services.FirstOrDefault(s => s.Name == $"ivy-staging-docs-{safe}");
var samplesSvc = services.FirstOrDefault(s => s.Name == $"ivy-staging-samples-{safe}");
var docsSvc = services.FirstOrDefault(s => s.Name == $"ivy-staging-docs-{prNumber}");
var samplesSvc = services.FirstOrDefault(s => s.Name == $"ivy-staging-samples-{prNumber}");

var triggered = 0;
if (docsSvc != null && await _sliplane.RedeployServiceAsync(apiToken, projectId, docsSvc.Id))
Expand All @@ -93,12 +94,11 @@ public async Task<StagingDeployResult> RedeployBranchAsync(string apiToken, stri
return new StagingDeployResult(triggered > 0, $"Redeploy triggered for {triggered} service(s).");
}

/// <summary>Resolves docs/samples URLs from existing Sliplane services for a branch (e.g. after redeploy).</summary>
public async Task<(string? DocsUrl, string? SamplesUrl)> GetDeploymentUrlsForBranchAsync(string apiToken, string branchName)
/// <summary>Resolves docs/samples URLs from existing Sliplane services for a PR (e.g. after redeploy).</summary>
public async Task<(string? DocsUrl, string? SamplesUrl)> GetDeploymentUrlsForPrAsync(string apiToken, int prNumber)
{
var list = await ListDeploymentsAsync(apiToken);
var safe = SliplaneStagingClient.SanitizeBranchName(branchName);
var dep = list.FirstOrDefault(d => string.Equals(d.BranchSafe, safe, StringComparison.OrdinalIgnoreCase));
var dep = list.FirstOrDefault(d => string.Equals(d.BranchSafe, prNumber.ToString(), StringComparison.OrdinalIgnoreCase));
if (dep == null)
return (null, null);
return (dep.DocsUrl, dep.SamplesUrl);
Expand All @@ -124,21 +124,19 @@ public async Task<StagingDeployResult> RedeployBranchAsync(string apiToken, stri
return (docsEvents, samplesEvents);
}

public async Task<StagingDeployment?> GetDeploymentByBranchAsync(string apiToken, string branchName)
public async Task<StagingDeployment?> GetDeploymentByPrNumberAsync(string apiToken, int prNumber)
{
var list = await ListDeploymentsAsync(apiToken);
var safe = SliplaneStagingClient.SanitizeBranchName(branchName);
return list.FirstOrDefault(d => string.Equals(d.BranchSafe, safe, StringComparison.OrdinalIgnoreCase));
return list.FirstOrDefault(d => string.Equals(d.BranchSafe, prNumber.ToString(), StringComparison.OrdinalIgnoreCase));
}

public async Task<StagingDeleteResult> DeleteBranchAsync(string apiToken, string branchName)
public async Task<StagingDeleteResult> DeleteBranchAsync(string apiToken, int prNumber)
{
var projectId = _config["Sliplane:ProjectId"] ?? "";
var safe = SliplaneStagingClient.SanitizeBranchName(branchName);

var services = await _sliplane.ListStagingServicesAsync(apiToken, projectId, "ivy-staging-");
var toDelete = services
.Where(s => s.Name == $"ivy-staging-docs-{safe}" || s.Name == $"ivy-staging-samples-{safe}")
.Where(s => s.Name == $"ivy-staging-docs-{prNumber}" || s.Name == $"ivy-staging-samples-{prNumber}")
.ToList();

var deleteTasks = toDelete.Select(svc => DeleteWithRetryAsync(apiToken, projectId, svc.Id));
Expand Down Expand Up @@ -219,15 +217,16 @@ public async Task<StagingDeleteResult> DeleteExpiredAsync(string apiToken)
return new StagingDeleteResult(false, "GitHub:Owner/Repo not configured, skipping expiry cleanup.");

var openPrs = await _github.GetPullRequestsAsync(owner, repo, ghToken, "open");
var openBranchSafes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var openPrNumbers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var pr in openPrs)
openBranchSafes.Add(SliplaneStagingClient.SanitizeBranchName(pr.HeadRef));
openPrNumbers.Add(pr.Number.ToString());

var toDelete = expired.Where(d => !openBranchSafes.Contains(d.BranchSafe)).ToList();
var toDelete = expired.Where(d => !openPrNumbers.Contains(d.BranchSafe)).ToList();
var deleted = 0;
foreach (var d in toDelete)
{
var r = await DeleteBranchAsync(apiToken, d.BranchSafe);
if (!int.TryParse(d.BranchSafe, out var prNum)) continue;
var r = await DeleteBranchAsync(apiToken, prNum);
if (r.Success) deleted++;
}
return new StagingDeleteResult(deleted > 0, $"Deleted {deleted} expired deployment(s) (closed PRs only).");
Expand Down
Loading