OPC # 0006: OPC Git Trunk-Based management
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using ControlPlane.Api.Services;
|
using ControlPlane.Api.Services;
|
||||||
|
using ControlPlane.Core.Services;
|
||||||
|
|
||||||
namespace ControlPlane.Api.Endpoints;
|
namespace ControlPlane.Api.Endpoints;
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ public static class ImageBuildEndpoints
|
|||||||
var group = app.MapGroup("/api/image").WithTags("Image");
|
var group = app.MapGroup("/api/image").WithTags("Image");
|
||||||
|
|
||||||
group.MapGet("/status", GetStatus);
|
group.MapGet("/status", GetStatus);
|
||||||
|
group.MapGet("/history", GetHistory);
|
||||||
group.MapPost("/build", TriggerBuild);
|
group.MapPost("/build", TriggerBuild);
|
||||||
|
|
||||||
// Post-provisioning verification helpers
|
// Post-provisioning verification helpers
|
||||||
@@ -28,6 +30,26 @@ public static class ImageBuildEndpoints
|
|||||||
private static async Task<IResult> GetStatus(ImageBuildService svc) =>
|
private static async Task<IResult> GetStatus(ImageBuildService svc) =>
|
||||||
Results.Ok(await svc.GetStatusAsync());
|
Results.Ok(await svc.GetStatusAsync());
|
||||||
|
|
||||||
|
/// <summary>Returns recent DockerImage build records for the sparkline chart.</summary>
|
||||||
|
private static async Task<IResult> GetHistory(BuildHistoryService history, int limit = 30)
|
||||||
|
{
|
||||||
|
var all = await history.GetBuildsAsync();
|
||||||
|
var records = all
|
||||||
|
.Where(b => b.Kind == ControlPlane.Core.Models.BuildKind.DockerImage)
|
||||||
|
.Take(Math.Clamp(limit, 1, 100))
|
||||||
|
.Select(b => new
|
||||||
|
{
|
||||||
|
b.Id,
|
||||||
|
b.Status,
|
||||||
|
b.StartedAt,
|
||||||
|
b.DurationMs,
|
||||||
|
b.CommitSha,
|
||||||
|
b.ImageDigest,
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
return Results.Ok(records);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Triggers a docker build and streams the output line-by-line as SSE.
|
/// Triggers a docker build and streams the output line-by-line as SSE.
|
||||||
/// The build context is the repo root, which must be configured via
|
/// The build context is the repo root, which must be configured via
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ public static class PromotionEndpoints
|
|||||||
{
|
{
|
||||||
var g = app.MapGroup("/api/promotions").WithTags("Promotions");
|
var g = app.MapGroup("/api/promotions").WithTags("Promotions");
|
||||||
|
|
||||||
// GET /api/promotions/ladder — branch status for all 4 ladder branches
|
// GET /api/promotions/ladder?repo=Clarity — branch status for all 4 ladder branches
|
||||||
g.MapGet("/ladder", async (PromotionService svc, CancellationToken ct) =>
|
g.MapGet("/ladder", async (PromotionService svc, CancellationToken ct, string repo = "Clarity") =>
|
||||||
Results.Ok(await svc.GetLadderStatusAsync(ct)));
|
Results.Ok(await svc.GetLadderStatusAsync(repo, ct)));
|
||||||
|
|
||||||
// GET /api/promotions/history
|
// GET /api/promotions/history
|
||||||
g.MapGet("/history", async (PromotionService svc) =>
|
g.MapGet("/history", async (PromotionService svc) =>
|
||||||
@@ -50,7 +50,7 @@ public static class PromotionEndpoints
|
|||||||
void OnLine(string line) => channel.Writer.TryWrite(line);
|
void OnLine(string line) => channel.Writer.TryWrite(line);
|
||||||
|
|
||||||
var promoteTask = Task.Run(() =>
|
var promoteTask = Task.Run(() =>
|
||||||
svc.PromoteAsync(req.From, req.To, req.RequestedBy ?? "system", req.Note, OnLine, ct), ct)
|
svc.PromoteAsync(req.From, req.To, req.RequestedBy ?? "system", req.Note, OnLine, ct, req.Repo ?? "Clarity"), ct)
|
||||||
.ContinueWith(t => channel.Writer.TryComplete(t.Exception), TaskScheduler.Default);
|
.ContinueWith(t => channel.Writer.TryComplete(t.Exception), TaskScheduler.Default);
|
||||||
|
|
||||||
await foreach (var line in channel.Reader.ReadAllAsync(ct))
|
await foreach (var line in channel.Reader.ReadAllAsync(ct))
|
||||||
@@ -66,8 +66,84 @@ public static class PromotionEndpoints
|
|||||||
await ctx.Response.Body.FlushAsync(ct);
|
await ctx.Response.Body.FlushAsync(ct);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/promotions/reset — body: { branch, toSha, repo }
|
||||||
|
// Force-resets a downstream branch to a specific SHA (e.g. to recover from a GitFlow merge commit).
|
||||||
|
// Only allowed for staging/uat — never develop or main.
|
||||||
|
g.MapPost("/reset", async (PromotionService svc, ResetBranchRequest req, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var allowed = new[] { "staging", "uat" };
|
||||||
|
if (!allowed.Contains(req.Branch))
|
||||||
|
return Results.BadRequest(new { error = $"Reset is only allowed for: {string.Join(", ", allowed)}." });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await svc.ResetBranchAsync(req.Branch, req.ToSha, req.Repo ?? "Clarity", ct);
|
||||||
|
return Results.Ok(new { reset = req.Branch, toSha = req.ToSha });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/promotions/cherry-pick — body: { shas, from, to, requestedBy, note, repo }
|
||||||
|
// Streams SSE log lines then sends {done, promotion} when complete.
|
||||||
|
// Unlike a full promote, cherry-pick applies selected commits as copies — branches will diverge.
|
||||||
|
g.MapPost("/cherry-pick", async (
|
||||||
|
HttpContext ctx,
|
||||||
|
PromotionService svc,
|
||||||
|
CherryPickRequest req,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var ladder = PromotionService.Ladder;
|
||||||
|
var fi = Array.IndexOf(ladder, req.From);
|
||||||
|
var ti = Array.IndexOf(ladder, req.To);
|
||||||
|
if (fi < 0 || ti < 0 || ti != fi + 1)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 400;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(
|
||||||
|
new { error = $"Invalid cherry-pick target: {req.From} → {req.To}. Must be adjacent in ladder." }, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.Shas is null || req.Shas.Length == 0)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 400;
|
||||||
|
await ctx.Response.WriteAsJsonAsync(
|
||||||
|
new { error = "No commits specified for cherry-pick." }, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Response.Headers.ContentType = "text/event-stream";
|
||||||
|
ctx.Response.Headers.CacheControl = "no-cache";
|
||||||
|
ctx.Response.Headers.Connection = "keep-alive";
|
||||||
|
|
||||||
|
var channel = System.Threading.Channels.Channel.CreateUnbounded<string?>(
|
||||||
|
new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = true, SingleReader = true });
|
||||||
|
|
||||||
|
void OnLine(string line) => channel.Writer.TryWrite(line);
|
||||||
|
|
||||||
|
var cpTask = Task.Run(() =>
|
||||||
|
svc.CherryPickAsync(req.Shas, req.From, req.To, req.RequestedBy ?? "system", req.Note, OnLine, ct, req.Repo ?? "Clarity"), ct)
|
||||||
|
.ContinueWith(t => channel.Writer.TryComplete(t.Exception), TaskScheduler.Default);
|
||||||
|
|
||||||
|
await foreach (var line in channel.Reader.ReadAllAsync(ct))
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(new { line }, JsonOpts);
|
||||||
|
await ctx.Response.WriteAsync($"data: {json}\n\n", ct);
|
||||||
|
await ctx.Response.Body.FlushAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var promotion = await cpTask;
|
||||||
|
var doneJson = JsonSerializer.Serialize(new { done = true, promotion }, JsonOpts);
|
||||||
|
await ctx.Response.WriteAsync($"data: {doneJson}\n\n", ct);
|
||||||
|
await ctx.Response.Body.FlushAsync(ct);
|
||||||
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record PromoteRequest(string From, string To, string? RequestedBy, string? Note);
|
public record PromoteRequest(string From, string To, string? RequestedBy, string? Note, string? Repo);
|
||||||
|
public record ResetBranchRequest(string Branch, string ToSha, string? Repo);
|
||||||
|
public record CherryPickRequest(string[] Shas, string From, string To, string? RequestedBy, string? Note, string? Repo);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using ControlPlane.Core.Models;
|
|||||||
using ControlPlane.Core.Services;
|
using ControlPlane.Core.Services;
|
||||||
using Docker.DotNet;
|
using Docker.DotNet;
|
||||||
using Docker.DotNet.Models;
|
using Docker.DotNet.Models;
|
||||||
|
using LibGit2Sharp;
|
||||||
|
|
||||||
namespace ControlPlane.Api.Services;
|
namespace ControlPlane.Api.Services;
|
||||||
|
|
||||||
@@ -49,6 +50,14 @@ public class ImageBuildService(
|
|||||||
|
|
||||||
var record = await history.CreateBuildAsync(BuildKind.DockerImage, ImageName);
|
var record = await history.CreateBuildAsync(BuildKind.DockerImage, ImageName);
|
||||||
|
|
||||||
|
// Capture HEAD SHA so the build is traceable back to a specific commit
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var repo = new Repository(repoRoot);
|
||||||
|
record.CommitSha = repo.Head.Tip?.Sha;
|
||||||
|
}
|
||||||
|
catch { /* not a git repo or no commits yet — CommitSha stays null */ }
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine";
|
var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine";
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
using ControlPlane.Core.Models;
|
using ControlPlane.Core.Models;
|
||||||
|
using LibGit2Sharp;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace ControlPlane.Api.Services;
|
namespace ControlPlane.Api.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles all git operations for the promotion workflow:
|
/// Handles all git operations for the promotion workflow using LibGit2Sharp.
|
||||||
/// branch status, diff summaries, merge + push, and promotion history persistence.
|
/// No git.exe subprocess is ever spawned — all operations run through the managed
|
||||||
/// All git commands run against the repo root configured in Docker:RepoRoot.
|
/// LibGit2Sharp API against the server's authoritative repository clone.
|
||||||
|
/// HEAD is never mutated; merges are performed directly on the object database
|
||||||
|
/// so the working tree always reflects the develop branch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PromotionService(IConfiguration config, ILogger<PromotionService> logger)
|
public class PromotionService(IConfiguration config, ILogger<PromotionService> logger)
|
||||||
{
|
{
|
||||||
// The ordered promotion ladder — each step is a valid promotion.
|
// The ordered promotion ladder — develop is trunk, main is production.
|
||||||
public static readonly string[] Ladder = ["develop", "staging", "uat", "master"];
|
public static readonly string[] Ladder = ["develop", "staging", "uat", "main"];
|
||||||
|
|
||||||
private string RepoRoot => config["Docker:RepoRoot"] ?? string.Empty;
|
private string GetRepoPath(string repoName) =>
|
||||||
|
config[$"Git:Repos:{repoName}"] ?? string.Empty;
|
||||||
|
|
||||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
@@ -26,67 +29,116 @@ public class PromotionService(IConfiguration config, ILogger<PromotionService> l
|
|||||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Credentials ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private FetchOptions MakeFetchOptions() => new()
|
||||||
|
{
|
||||||
|
CredentialsProvider = (_, _, _) => new UsernamePasswordCredentials
|
||||||
|
{
|
||||||
|
Username = config["Gitea:Owner"] ?? "git",
|
||||||
|
Password = config["Gitea:Token"] ?? string.Empty,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
private PushOptions MakePushOptions() => new()
|
||||||
|
{
|
||||||
|
CredentialsProvider = (_, _, _) => new UsernamePasswordCredentials
|
||||||
|
{
|
||||||
|
Username = config["Gitea:Owner"] ?? "git",
|
||||||
|
Password = config["Gitea:Token"] ?? string.Empty,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Signature MakeSig() =>
|
||||||
|
new("OPC Control Plane", "opc@clarity.internal", DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
// ── Branch status ────────────────────────────────────────────────────────
|
// ── Branch status ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns status for all ladder branches: last commit info + ahead/behind counts vs next branch.
|
/// Returns status for all ladder branches: last commit info + ahead/behind counts vs next branch.
|
||||||
|
/// Runs on a thread-pool thread because LibGit2Sharp network operations are synchronous.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<BranchStatus>> GetLadderStatusAsync(CancellationToken ct = default)
|
public Task<List<BranchStatus>> GetLadderStatusAsync(string repoName = "Clarity", CancellationToken ct = default) =>
|
||||||
|
Task.Run(() => GetLadderStatusCore(repoName, ct), ct);
|
||||||
|
|
||||||
|
private List<BranchStatus> GetLadderStatusCore(string repoName, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var repoPath = GetRepoPath(repoName);
|
||||||
|
if (string.IsNullOrWhiteSpace(repoPath) || !Directory.Exists(repoPath))
|
||||||
|
return Ladder.Select(b => new BranchStatus(b, false, null, null, 0, 0, [])).ToList();
|
||||||
|
|
||||||
|
using var repo = new Repository(repoPath);
|
||||||
|
|
||||||
|
// Fetch to get up-to-date remote refs; swallow network errors so status still works offline.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var remote = repo.Network.Remotes["origin"];
|
||||||
|
if (remote is not null)
|
||||||
|
{
|
||||||
|
var refSpecs = remote.FetchRefSpecs.Select(r => r.Specification).ToList();
|
||||||
|
repo.Network.Fetch(remote.Name, refSpecs, MakeFetchOptions());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Fetch during ladder status failed — continuing with cached refs");
|
||||||
|
}
|
||||||
|
|
||||||
var result = new List<BranchStatus>();
|
var result = new List<BranchStatus>();
|
||||||
|
|
||||||
// Fetch to get up-to-date remote state, but don't fail if we're offline
|
for (var i = 0; i < Ladder.Length; i++)
|
||||||
await RunGitAsync("fetch --all --quiet", ct, swallowErrors: true);
|
|
||||||
|
|
||||||
foreach (var branch in Ladder)
|
|
||||||
{
|
{
|
||||||
var exists = await BranchExistsAsync(branch, ct);
|
ct.ThrowIfCancellationRequested();
|
||||||
if (!exists)
|
|
||||||
|
var branchName = Ladder[i];
|
||||||
|
var branch = repo.Branches[branchName];
|
||||||
|
|
||||||
|
if (branch?.Tip is null)
|
||||||
{
|
{
|
||||||
result.Add(new BranchStatus(branch, false, null, null, 0, 0, []));
|
result.Add(new BranchStatus(branchName, false, null, null, 0, 0, []));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last commit on this branch
|
var tip = branch.Tip;
|
||||||
var lastCommit = await GitOutputAsync($"log {branch} -1 --format=%h|%an|%ad|%s --date=short", ct);
|
var when = tip.Author.When;
|
||||||
string? shortHash = null, author = null, date = null, subject = null;
|
var summary = $"{tip.Author.Name} · {when:yyyy-MM-dd} · {tip.MessageShort}";
|
||||||
if (!string.IsNullOrWhiteSpace(lastCommit))
|
|
||||||
{
|
|
||||||
var p = lastCommit.Trim().Split('|', 4);
|
|
||||||
if (p.Length == 4) (shortHash, author, date, subject) = (p[0], p[1], p[2], p[3]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ahead/behind vs the NEXT branch in the ladder
|
// Ahead/behind vs the next branch in the ladder
|
||||||
int ahead = 0, behind = 0;
|
int ahead = 0;
|
||||||
var nextIdx = Array.IndexOf(Ladder, branch) + 1;
|
int behind = 0;
|
||||||
if (nextIdx < Ladder.Length)
|
CommitInfo[] unreleasedCommits = [];
|
||||||
|
|
||||||
|
if (i + 1 < Ladder.Length)
|
||||||
{
|
{
|
||||||
var next = Ladder[nextIdx];
|
var nextBranch = repo.Branches[Ladder[i + 1]];
|
||||||
if (await BranchExistsAsync(next, ct))
|
if (nextBranch?.Tip is not null)
|
||||||
{
|
{
|
||||||
var counts = await GitOutputAsync($"rev-list --left-right --count {next}...{branch}", ct);
|
var div = repo.ObjectDatabase.CalculateHistoryDivergence(tip, nextBranch.Tip);
|
||||||
if (!string.IsNullOrWhiteSpace(counts))
|
ahead = div.AheadBy ?? 0;
|
||||||
|
behind = div.BehindBy ?? 0;
|
||||||
|
|
||||||
|
if (ahead > 0)
|
||||||
{
|
{
|
||||||
var parts = counts.Trim().Split('\t');
|
unreleasedCommits = repo.Commits
|
||||||
if (parts.Length == 2)
|
.QueryBy(new CommitFilter
|
||||||
{
|
{
|
||||||
int.TryParse(parts[0], out behind);
|
IncludeReachableFrom = tip,
|
||||||
int.TryParse(parts[1], out ahead);
|
ExcludeReachableFrom = nextBranch.Tip,
|
||||||
}
|
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time,
|
||||||
|
})
|
||||||
|
.Select(c => new CommitInfo(
|
||||||
|
c.Sha,
|
||||||
|
c.Sha[..7],
|
||||||
|
c.MessageShort,
|
||||||
|
c.Author.Name,
|
||||||
|
c.Author.When.ToString("yyyy-MM-dd")))
|
||||||
|
.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unreleased commit summaries (commits in this branch not yet in next)
|
result.Add(new BranchStatus(branchName, true, tip.Sha[..7], summary,
|
||||||
string[] unreleasedLines = [];
|
ahead, behind, unreleasedCommits));
|
||||||
if (ahead > 0 && nextIdx < Ladder.Length && await BranchExistsAsync(Ladder[nextIdx], ct))
|
|
||||||
{
|
|
||||||
var log = await GitOutputAsync($"log {Ladder[nextIdx]}..{branch} --oneline --no-decorate", ct);
|
|
||||||
unreleasedLines = log.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Add(new BranchStatus(branch, true, shortHash, $"{author} · {date} · {subject}",
|
|
||||||
ahead, behind, unreleasedLines));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -96,7 +148,8 @@ public class PromotionService(IConfiguration config, ILogger<PromotionService> l
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Merges <paramref name="from"/> into <paramref name="to"/> with a no-fast-forward merge commit,
|
/// Merges <paramref name="from"/> into <paramref name="to"/> with a no-fast-forward merge commit,
|
||||||
/// then pushes. Streams progress lines to <paramref name="onLine"/>.
|
/// then pushes. HEAD is never mutated — the working tree stays on develop throughout.
|
||||||
|
/// Streams progress lines to <paramref name="onLine"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<PromotionRequest> PromoteAsync(
|
public async Task<PromotionRequest> PromoteAsync(
|
||||||
string from,
|
string from,
|
||||||
@@ -104,7 +157,8 @@ public class PromotionService(IConfiguration config, ILogger<PromotionService> l
|
|||||||
string requestedBy,
|
string requestedBy,
|
||||||
string? note,
|
string? note,
|
||||||
Action<string> onLine,
|
Action<string> onLine,
|
||||||
CancellationToken ct)
|
CancellationToken ct,
|
||||||
|
string repoName = "Clarity")
|
||||||
{
|
{
|
||||||
if (!await _lock.WaitAsync(TimeSpan.Zero, ct))
|
if (!await _lock.WaitAsync(TimeSpan.Zero, ct))
|
||||||
{
|
{
|
||||||
@@ -126,57 +180,13 @@ public class PromotionService(IConfiguration config, ILogger<PromotionService> l
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Log($"▶ Promoting {from} → {to}");
|
await Task.Run(() => PromoteCore(from, to, note, repoName, req, Log, ct), ct);
|
||||||
if (!string.IsNullOrWhiteSpace(note)) Log($" Note: {note}");
|
|
||||||
Log("──────────────────────────────────────");
|
|
||||||
|
|
||||||
// 1. Fetch latest
|
|
||||||
Log(" git fetch --all");
|
|
||||||
await RunGitAsync("fetch --all --quiet", ct);
|
|
||||||
|
|
||||||
// 2. Checkout target branch
|
|
||||||
Log($" git checkout {to}");
|
|
||||||
await RunGitAsync($"checkout {to}", ct);
|
|
||||||
|
|
||||||
// 3. Pull target to latest
|
|
||||||
Log($" git pull origin {to}");
|
|
||||||
await RunGitAsync($"pull origin {to} --quiet", ct);
|
|
||||||
|
|
||||||
// 4. Count commits being promoted
|
|
||||||
var logOutput = await GitOutputAsync($"log {to}..{from} --oneline --no-decorate", ct);
|
|
||||||
var commitLines = logOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
req.CommitCount = commitLines.Length;
|
|
||||||
req.CommitLines = commitLines;
|
|
||||||
Log($" Merging {commitLines.Length} commit(s) from {from}:");
|
|
||||||
foreach (var cl in commitLines) Log($" {cl}");
|
|
||||||
|
|
||||||
// 5. Merge with --no-ff for a clean promotion commit
|
|
||||||
var mergeMsg = $"chore: promote {from} → {to}" + (note != null ? $" — {note}" : "");
|
|
||||||
Log($" git merge --no-ff {from}");
|
|
||||||
await RunGitAsync($"merge --no-ff {from} -m \"{mergeMsg}\"", ct);
|
|
||||||
|
|
||||||
// 6. Push
|
|
||||||
Log($" git push origin {to}");
|
|
||||||
await RunGitAsync($"push origin {to}", ct);
|
|
||||||
|
|
||||||
// 7. Return to develop so the working tree stays clean
|
|
||||||
await RunGitAsync("checkout develop", ct, swallowErrors: true);
|
|
||||||
|
|
||||||
Log("──────────────────────────────────────");
|
|
||||||
Log($"✔ {from} → {to} promoted successfully at {DateTimeOffset.UtcNow:u}");
|
|
||||||
req.Status = PromotionStatus.Succeeded;
|
|
||||||
req.CompletedAt = DateTimeOffset.UtcNow;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log($"✖ Promotion failed: {ex.Message}");
|
Log($"✖ Promotion failed: {ex.Message}");
|
||||||
req.Status = PromotionStatus.Failed;
|
req.Status = PromotionStatus.Failed;
|
||||||
req.CompletedAt = DateTimeOffset.UtcNow;
|
req.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
// Try to abort any broken merge state
|
|
||||||
await RunGitAsync("merge --abort", ct, swallowErrors: true);
|
|
||||||
await RunGitAsync("checkout develop", ct, swallowErrors: true);
|
|
||||||
|
|
||||||
logger.LogError(ex, "Promotion {From}→{To} failed", from, to);
|
logger.LogError(ex, "Promotion {From}→{To} failed", from, to);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -188,6 +198,329 @@ public class PromotionService(IConfiguration config, ILogger<PromotionService> l
|
|||||||
return req;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void PromoteCore(
|
||||||
|
string from,
|
||||||
|
string to,
|
||||||
|
string? note,
|
||||||
|
string repoName,
|
||||||
|
PromotionRequest req,
|
||||||
|
Action<string> Log,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
Log($"▶ Promoting {from} → {to} [{repoName}]");
|
||||||
|
if (!string.IsNullOrWhiteSpace(note)) Log($" Note: {note}");
|
||||||
|
Log("──────────────────────────────────────");
|
||||||
|
|
||||||
|
using var repo = new Repository(GetRepoPath(repoName));
|
||||||
|
|
||||||
|
// 1. Fetch latest remote state for all branches
|
||||||
|
Log(" Fetching origin...");
|
||||||
|
var remote = repo.Network.Remotes["origin"]
|
||||||
|
?? throw new InvalidOperationException("No 'origin' remote configured.");
|
||||||
|
var refSpecs = remote.FetchRefSpecs.Select(r => r.Specification).ToList();
|
||||||
|
repo.Network.Fetch(remote.Name, refSpecs, MakeFetchOptions());
|
||||||
|
|
||||||
|
// 2. Resolve local branches
|
||||||
|
var fromBranch = repo.Branches[from]
|
||||||
|
?? throw new InvalidOperationException($"Branch '{from}' not found.");
|
||||||
|
var toBranch = repo.Branches[to]
|
||||||
|
?? throw new InvalidOperationException($"Branch '{to}' not found.");
|
||||||
|
|
||||||
|
// 3. Fast-forward local `to` to its remote tracking branch (equivalent to git pull --ff-only)
|
||||||
|
var remoteTracking = repo.Branches[$"origin/{to}"];
|
||||||
|
if (remoteTracking?.Tip is not null && toBranch.Tip.Sha != remoteTracking.Tip.Sha)
|
||||||
|
{
|
||||||
|
Log($" Fast-forwarding {to} to origin/{to}...");
|
||||||
|
repo.Refs.UpdateTarget(toBranch.Reference.CanonicalName, remoteTracking.Tip.Sha);
|
||||||
|
toBranch = repo.Branches[to]!; // refresh after update
|
||||||
|
}
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var fromTip = fromBranch.Tip;
|
||||||
|
var toTip = toBranch.Tip;
|
||||||
|
|
||||||
|
// 4. Enumerate commits being promoted
|
||||||
|
var pendingCommits = repo.Commits.QueryBy(new CommitFilter
|
||||||
|
{
|
||||||
|
IncludeReachableFrom = fromTip,
|
||||||
|
ExcludeReachableFrom = toTip,
|
||||||
|
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time,
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
if (pendingCommits.Count == 0)
|
||||||
|
{
|
||||||
|
Log($" ℹ {to} is already up-to-date with {from}. Nothing to promote.");
|
||||||
|
req.Status = PromotionStatus.Succeeded;
|
||||||
|
req.CommitCount = 0;
|
||||||
|
req.CommitLines = [];
|
||||||
|
req.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.CommitCount = pendingCommits.Count;
|
||||||
|
req.CommitLines = pendingCommits.Select(c => $"{c.Sha[..7]} {c.MessageShort}").ToArray();
|
||||||
|
Log($" {pendingCommits.Count} commit(s) to promote:");
|
||||||
|
foreach (var cl in req.CommitLines) Log($" {cl}");
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
// 5. Safety check: `from` must be a descendant of `to` (fast-forward is only possible
|
||||||
|
// when the target branch has no commits that aren't already reachable from source).
|
||||||
|
// This is the TBD invariant — staging/uat/main are always subsets of develop's linear history.
|
||||||
|
var isAncestor = repo.ObjectDatabase.FindMergeBase(fromTip, toTip)?.Sha == toTip.Sha;
|
||||||
|
if (!isAncestor)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"'{to}' has commits not in '{from}' — fast-forward is not possible. " +
|
||||||
|
$"This means '{to}' diverged from trunk. " +
|
||||||
|
$"Check whether a hotfix was committed directly to '{to}' without being backported to '{from}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Fast-forward: advance the local `to` ref to `from`'s tip — no merge commit, linear history.
|
||||||
|
// Equivalent to: git push origin {from}:{to}
|
||||||
|
// HEAD is never mutated, working tree is untouched.
|
||||||
|
var oldToSha = toTip.Sha;
|
||||||
|
repo.Refs.UpdateTarget(toBranch.Reference.CanonicalName, fromTip.Sha);
|
||||||
|
Log($" Fast-forward: refs/heads/{to} {oldToSha[..7]} → {fromTip.Sha[..7]}");
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
// 7. Push to origin; roll back the local ref if push fails so nothing is left half-done
|
||||||
|
Log($" Pushing {to} to origin...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Network.Push(remote, $"refs/heads/{to}:refs/heads/{to}", MakePushOptions());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
repo.Refs.UpdateTarget(toBranch.Reference.CanonicalName, oldToSha);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log("──────────────────────────────────────");
|
||||||
|
Log($"✔ {from} → {to} promoted successfully ({pendingCommits.Count} commit(s)) at {DateTimeOffset.UtcNow:u}");
|
||||||
|
req.Status = PromotionStatus.Succeeded;
|
||||||
|
req.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Branch reset (recovery) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Force-resets <paramref name="branchName"/> to <paramref name="toSha"/> and force-pushes to origin.
|
||||||
|
/// Used to recover a downstream branch that has drifted from trunk (e.g. after an accidental merge commit).
|
||||||
|
/// </summary>
|
||||||
|
public Task ResetBranchAsync(string branchName, string toSha, string repoName, CancellationToken ct) =>
|
||||||
|
Task.Run(() =>
|
||||||
|
{
|
||||||
|
var repoPath = GetRepoPath(repoName);
|
||||||
|
using var repo = new Repository(repoPath);
|
||||||
|
|
||||||
|
var commit = repo.Lookup<Commit>(toSha)
|
||||||
|
?? throw new InvalidOperationException($"SHA '{toSha}' not found in {repoName}.");
|
||||||
|
|
||||||
|
var branch = repo.Branches[branchName]
|
||||||
|
?? throw new InvalidOperationException($"Branch '{branchName}' not found in {repoName}.");
|
||||||
|
|
||||||
|
var oldSha = branch.Tip.Sha;
|
||||||
|
repo.Refs.UpdateTarget(branch.Reference.CanonicalName, commit.Sha);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var remote = repo.Network.Remotes["origin"]
|
||||||
|
?? throw new InvalidOperationException("No 'origin' remote.");
|
||||||
|
// Force push — "+" prefix overrides remote reflog
|
||||||
|
repo.Network.Push(remote, $"+refs/heads/{branchName}:refs/heads/{branchName}", MakePushOptions());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
repo.Refs.UpdateTarget(branch.Reference.CanonicalName, oldSha);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Reset {Branch} from {Old} to {New} in {Repo}", branchName, oldSha[..7], commit.Sha[..7], repoName);
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
// ── Cherry-pick (partial promotion) ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cherry-picks the specified commits from <paramref name="from"/> onto <paramref name="to"/>
|
||||||
|
/// and pushes. Unlike a full fast-forward promotion, cherry-pick copies individual commits
|
||||||
|
/// as new commits — useful for promoting a subset of changes to a downstream environment.
|
||||||
|
/// Note: cherry-pick will cause the target branch to diverge from trunk.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PromotionRequest> CherryPickAsync(
|
||||||
|
string[] shas,
|
||||||
|
string from,
|
||||||
|
string to,
|
||||||
|
string requestedBy,
|
||||||
|
string? note,
|
||||||
|
Action<string> onLine,
|
||||||
|
CancellationToken ct,
|
||||||
|
string repoName = "Clarity")
|
||||||
|
{
|
||||||
|
if (!await _lock.WaitAsync(TimeSpan.Zero, ct))
|
||||||
|
{
|
||||||
|
var busy = new PromotionRequest { FromBranch = from, ToBranch = to, Status = PromotionStatus.Failed };
|
||||||
|
busy.Log.Add("⚠️ Another promotion or cherry-pick is already in progress.");
|
||||||
|
return busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
var req = new PromotionRequest
|
||||||
|
{
|
||||||
|
FromBranch = from,
|
||||||
|
ToBranch = to,
|
||||||
|
RequestedBy = requestedBy,
|
||||||
|
Note = note,
|
||||||
|
Status = PromotionStatus.Running,
|
||||||
|
};
|
||||||
|
|
||||||
|
void Log(string line) { req.Log.Add(line); onLine(line); }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Run(() => CherryPickCore(shas, from, to, repoName, req, Log, ct), ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log($"✖ Cherry-pick failed: {ex.Message}");
|
||||||
|
req.Status = PromotionStatus.Failed;
|
||||||
|
req.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
|
logger.LogError(ex, "Cherry-pick {From}→{To} failed", from, to);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await SaveAsync(req);
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CherryPickCore(
|
||||||
|
string[] shas,
|
||||||
|
string from,
|
||||||
|
string to,
|
||||||
|
string repoName,
|
||||||
|
PromotionRequest req,
|
||||||
|
Action<string> Log,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
Log($"▶ Cherry-pick {shas.Length} commit(s): {from} → {to} [{repoName}]");
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Note)) Log($" Note: {req.Note}");
|
||||||
|
Log("──────────────────────────────────────");
|
||||||
|
|
||||||
|
using var repo = new Repository(GetRepoPath(repoName));
|
||||||
|
|
||||||
|
if (repo.Info.IsBare)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Cherry-pick requires a non-bare repository clone. " +
|
||||||
|
"Ensure Git:Repos:{name} points to a standard (non-bare) clone.");
|
||||||
|
|
||||||
|
// 1. Fetch
|
||||||
|
Log(" Fetching origin...");
|
||||||
|
var remote = repo.Network.Remotes["origin"]
|
||||||
|
?? throw new InvalidOperationException("No 'origin' remote configured.");
|
||||||
|
var refSpecs = remote.FetchRefSpecs.Select(r => r.Specification).ToList();
|
||||||
|
repo.Network.Fetch(remote.Name, refSpecs, MakeFetchOptions());
|
||||||
|
|
||||||
|
// 2. Resolve target branch
|
||||||
|
var toBranch = repo.Branches[to]
|
||||||
|
?? throw new InvalidOperationException($"Branch '{to}' not found.");
|
||||||
|
|
||||||
|
// 3. Fast-forward `to` to its remote tracking branch (sync with origin)
|
||||||
|
var remoteTracking = repo.Branches[$"origin/{to}"];
|
||||||
|
if (remoteTracking?.Tip is not null && toBranch.Tip.Sha != remoteTracking.Tip.Sha)
|
||||||
|
{
|
||||||
|
Log($" Fast-forwarding {to} to origin/{to}...");
|
||||||
|
repo.Refs.UpdateTarget(toBranch.Reference.CanonicalName, remoteTracking.Tip.Sha);
|
||||||
|
toBranch = repo.Branches[to]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedToSha = toBranch.Tip.Sha;
|
||||||
|
var originalHeadBranchName = repo.Head.FriendlyName;
|
||||||
|
|
||||||
|
// 4. Resolve commits — shas arrive newest-first from UI (topological order);
|
||||||
|
// reverse so we apply oldest → newest (preserves logical order in history).
|
||||||
|
var commitsOrdered = shas
|
||||||
|
.Select(sha => repo.Lookup<Commit>(sha)
|
||||||
|
?? throw new InvalidOperationException($"Commit '{sha}' not found in {repoName}."))
|
||||||
|
.Reverse()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
req.CommitCount = commitsOrdered.Count;
|
||||||
|
req.CommitLines = commitsOrdered.Select(c => $"{c.Sha[..7]} {c.MessageShort}").ToArray();
|
||||||
|
Log($" {commitsOrdered.Count} commit(s) to apply (oldest → newest):");
|
||||||
|
foreach (var c in commitsOrdered) Log($" {c.Sha[..7]} {c.MessageShort}");
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
// 5. Force-checkout target branch — discards any dirty working tree state left by a
|
||||||
|
// previous failed cherry-pick or interrupted operation. This is a server-only clone
|
||||||
|
// managed exclusively by the control plane, so force is always safe here.
|
||||||
|
Log($" Checking out {to} (force)...");
|
||||||
|
var forceCheckout = new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force };
|
||||||
|
Commands.Checkout(repo, toBranch, forceCheckout);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sig = MakeSig();
|
||||||
|
foreach (var commit in commitsOrdered)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
Log($" Applying {commit.Sha[..7]} {commit.MessageShort}...");
|
||||||
|
|
||||||
|
var result = repo.CherryPick(commit, sig);
|
||||||
|
switch (result.Status)
|
||||||
|
{
|
||||||
|
case CherryPickStatus.CherryPicked:
|
||||||
|
Log($" \u2714 \u2192 {result.Commit!.Sha[..7]}");
|
||||||
|
break;
|
||||||
|
case CherryPickStatus.Conflicts:
|
||||||
|
Log($" \u2716 Conflict \u2014 aborting and rolling back");
|
||||||
|
repo.Reset(ResetMode.Hard, repo.Lookup<Commit>(savedToSha));
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Cherry-pick conflict on {commit.Sha[..7]}: {commit.MessageShort}. " +
|
||||||
|
"Resolve conflicts manually or promote a different set of commits.");
|
||||||
|
default:
|
||||||
|
Log($" \u2261 Already present or no changes \u2014 skipped");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Push
|
||||||
|
Log($" Pushing {to} to origin...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repo.Network.Push(remote, $"refs/heads/{to}:refs/heads/{to}", MakePushOptions());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
repo.Reset(ResetMode.Hard, repo.Lookup<Commit>(savedToSha));
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log("──────────────────────────────────────");
|
||||||
|
Log($"✔ Cherry-picked {commitsOrdered.Count} commit(s) to {to} at {DateTimeOffset.UtcNow:u}");
|
||||||
|
req.Status = PromotionStatus.Succeeded;
|
||||||
|
req.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Always restore HEAD to the original branch regardless of outcome
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var headBranch = repo.Branches[originalHeadBranchName];
|
||||||
|
if (headBranch is not null)
|
||||||
|
Commands.Checkout(repo, headBranch, new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Could not restore HEAD to '{Branch}' after cherry-pick", originalHeadBranchName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── History persistence ──────────────────────────────────────────────────
|
// ── History persistence ──────────────────────────────────────────────────
|
||||||
|
|
||||||
private string HistoryPath
|
private string HistoryPath
|
||||||
@@ -230,54 +563,18 @@ public class PromotionService(IConfiguration config, ILogger<PromotionService> l
|
|||||||
try { return JsonSerializer.Deserialize<List<PromotionRequest>>(File.ReadAllText(HistoryPath), JsonOpts) ?? []; }
|
try { return JsonSerializer.Deserialize<List<PromotionRequest>>(File.ReadAllText(HistoryPath), JsonOpts) ?? []; }
|
||||||
catch { return []; }
|
catch { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Git helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async Task<bool> BranchExistsAsync(string branch, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var output = await GitOutputAsync($"branch --list {branch}", ct);
|
|
||||||
return !string.IsNullOrWhiteSpace(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string> GitOutputAsync(string args, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var psi = MakePsi(args);
|
|
||||||
using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start git");
|
|
||||||
var output = await proc.StandardOutput.ReadToEndAsync(ct);
|
|
||||||
await proc.WaitForExitAsync(ct);
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunGitAsync(string args, CancellationToken ct, bool swallowErrors = false)
|
|
||||||
{
|
|
||||||
var psi = MakePsi(args);
|
|
||||||
using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start git");
|
|
||||||
var stderr = await proc.StandardError.ReadToEndAsync(ct);
|
|
||||||
await proc.WaitForExitAsync(ct);
|
|
||||||
|
|
||||||
if (!swallowErrors && proc.ExitCode != 0)
|
|
||||||
throw new InvalidOperationException($"git {args} exited {proc.ExitCode}: {stderr.Trim()}");
|
|
||||||
|
|
||||||
logger.LogDebug("git {Args} → exit {Code}", args, proc.ExitCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ProcessStartInfo MakePsi(string args) => new("git", args)
|
|
||||||
{
|
|
||||||
WorkingDirectory = RepoRoot,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A single unreleased commit — carries full SHA for cherry-pick operations.</summary>
|
||||||
|
public record CommitInfo(string Sha, string ShortSha, string Message, string Author, string Date);
|
||||||
|
|
||||||
/// <summary>Current status of a single branch in the promotion ladder.</summary>
|
/// <summary>Current status of a single branch in the promotion ladder.</summary>
|
||||||
public record BranchStatus(
|
public record BranchStatus(
|
||||||
string Branch,
|
string Branch,
|
||||||
bool Exists,
|
bool Exists,
|
||||||
string? ShortHash,
|
string? ShortHash,
|
||||||
string? LastCommitSummary,
|
string? LastCommitSummary,
|
||||||
int AheadOfNext, // commits this branch has that the next doesn't
|
int AheadOfNext, // commits this branch has that the next doesn't
|
||||||
int BehindNext, // commits next has that this branch doesn't (shouldn't happen in clean flow)
|
int BehindNext, // commits next has that this branch doesn't (diverged)
|
||||||
string[] UnreleasedLines // oneline log of the ahead commits
|
CommitInfo[] UnreleasedCommits // rich commit objects for cherry-pick UI
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ public class BuildRecord
|
|||||||
public DateTimeOffset? FinishedAt { get; set; }
|
public DateTimeOffset? FinishedAt { get; set; }
|
||||||
public int? DurationMs { get; set; }
|
public int? DurationMs { get; set; }
|
||||||
public string? ImageDigest { get; set; } // populated for DockerImage builds
|
public string? ImageDigest { get; set; } // populated for DockerImage builds
|
||||||
|
public string? CommitSha { get; set; } // HEAD SHA at build time
|
||||||
public List<string> Log { get; set; } = [];
|
public List<string> Log { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,14 +201,37 @@ export async function getGitLog(path?: string, limit = 20): Promise<GitCommit[]>
|
|||||||
|
|
||||||
// ── Promotion / Branch Ladder API ────────────────────────────────────────────
|
// ── Promotion / Branch Ladder API ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BuildHistoryRecord {
|
||||||
|
id: string;
|
||||||
|
status: 'Running' | 'Succeeded' | 'Failed';
|
||||||
|
startedAt: string;
|
||||||
|
durationMs: number | null;
|
||||||
|
commitSha: string | null;
|
||||||
|
imageDigest: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getImageBuildHistory(limit = 30): Promise<BuildHistoryRecord[]> {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/image/history?limit=${limit}`);
|
||||||
|
if (!res.ok) throw new Error(`Failed to get build history: ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitInfo {
|
||||||
|
sha: string;
|
||||||
|
shortSha: string;
|
||||||
|
message: string;
|
||||||
|
author: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BranchStatus {
|
export interface BranchStatus {
|
||||||
branch: string;
|
branch: string;
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
shortHash: string | null;
|
shortHash: string | null;
|
||||||
lastCommitSummary: string | null;
|
lastCommitSummary: string | null;
|
||||||
aheadOfNext: number;
|
aheadOfNext: number;
|
||||||
behindNext: number;
|
behindNext: number;
|
||||||
unreleasedLines: string[];
|
unreleasedCommits: CommitInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromotionRecord {
|
export interface PromotionRecord {
|
||||||
@@ -225,8 +248,8 @@ export interface PromotionRecord {
|
|||||||
log: string[];
|
log: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLadderStatus(): Promise<BranchStatus[]> {
|
export async function getLadderStatus(repo = 'Clarity'): Promise<BranchStatus[]> {
|
||||||
const res = await fetch(`${BASE_URL}/api/promotions/ladder`);
|
const res = await fetch(`${BASE_URL}/api/promotions/ladder?repo=${encodeURIComponent(repo)}`);
|
||||||
if (!res.ok) throw new Error(`Failed to get ladder status: ${res.statusText}`);
|
if (!res.ok) throw new Error(`Failed to get ladder status: ${res.statusText}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -246,6 +269,7 @@ export function triggerPromotion(
|
|||||||
onLine: (line: string) => void,
|
onLine: (line: string) => void,
|
||||||
onDone: (record: PromotionRecord) => void,
|
onDone: (record: PromotionRecord) => void,
|
||||||
onError: (err: string) => void,
|
onError: (err: string) => void,
|
||||||
|
repo = 'Clarity',
|
||||||
): () => void {
|
): () => void {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -255,7 +279,73 @@ export function triggerPromotion(
|
|||||||
const res = await fetch(`${BASE_URL}/api/promotions/promote`, {
|
const res = await fetch(`${BASE_URL}/api/promotions/promote`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ from, to, requestedBy, note }),
|
body: JSON.stringify({ from, to, requestedBy, note, repo }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok || !res.body) { onError(res.statusText); return; }
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (!cancelled) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const parts = buffer.split('\n\n');
|
||||||
|
buffer = parts.pop() ?? '';
|
||||||
|
for (const chunk of parts) {
|
||||||
|
const dataLine = chunk.replace(/^data:\s*/m, '').trim();
|
||||||
|
if (!dataLine) continue;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(dataLine);
|
||||||
|
if (msg.done && msg.promotion) onDone(msg.promotion as PromotionRecord);
|
||||||
|
else if (typeof msg.line === 'string') onLine(msg.line);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) onError(e instanceof Error ? e.message : 'Unknown error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { cancelled = true; controller.abort(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetBranch(branch: string, toSha: string, repo: string): Promise<void> {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/promotions/reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ branch, toSha, repo }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((body as { error?: string }).error ?? res.statusText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cherry-picks the specified commits (by full SHA) from `from` to `to` and streams SSE progress. */
|
||||||
|
export function triggerCherryPick(
|
||||||
|
shas: string[],
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
requestedBy: string,
|
||||||
|
note: string | undefined,
|
||||||
|
onLine: (line: string) => void,
|
||||||
|
onDone: (record: PromotionRecord) => void,
|
||||||
|
onError: (err: string) => void,
|
||||||
|
repo = 'Clarity',
|
||||||
|
): () => void {
|
||||||
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/promotions/cherry-pick`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ shas, from, to, requestedBy, note, repo }),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Callout, Intent, Tag, Spinner,
|
Button, Callout, Checkbox, Intent, Tag, Spinner,
|
||||||
Dialog, DialogBody, DialogFooter,
|
Dialog, DialogBody, DialogFooter,
|
||||||
HTMLTable, Collapse, Card, Elevation, TextArea,
|
HTMLTable, Collapse, TextArea, SegmentedControl,
|
||||||
} from '@blueprintjs/core';
|
} from '@blueprintjs/core';
|
||||||
import {
|
import {
|
||||||
getLadderStatus, getPromotionHistory, triggerPromotion,
|
getLadderStatus, getPromotionHistory, triggerPromotion, triggerCherryPick,
|
||||||
type BranchStatus, type PromotionRecord,
|
getImageBuildHistory, resetBranch,
|
||||||
|
type BranchStatus, type CommitInfo, type PromotionRecord, type BuildHistoryRecord,
|
||||||
} from '../api/provisioningApi';
|
} from '../api/provisioningApi';
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const LADDER: { branch: string; label: string; env: string; intent: Intent }[] = [
|
const REPOS = ['Clarity', 'OPC', 'Gateway'] as const;
|
||||||
{ branch: 'develop', label: 'Develop', env: 'fdev', intent: Intent.PRIMARY },
|
type RepoName = typeof REPOS[number];
|
||||||
{ branch: 'staging', label: 'Staging', env: 'staging', intent: Intent.WARNING },
|
|
||||||
{ branch: 'uat', label: 'UAT', env: 'uat', intent: Intent.DANGER },
|
const LADDER: { branch: string; label: string; env: string; intent: Intent; color: string }[] = [
|
||||||
{ branch: 'master', label: 'Master', env: 'prod', intent: Intent.SUCCESS },
|
{ branch: 'develop', label: 'Develop', env: 'dev', intent: Intent.PRIMARY, color: '#215db0' },
|
||||||
|
{ branch: 'staging', label: 'Staging', env: 'staging', intent: Intent.WARNING, color: '#935610' },
|
||||||
|
{ branch: 'uat', label: 'UAT', env: 'uat', intent: Intent.DANGER, color: '#8e292c' },
|
||||||
|
{ branch: 'main', label: 'Main', env: 'prod', intent: Intent.SUCCESS, color: '#1c6e42' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_INTENT: Record<string, Intent> = {
|
const STATUS_INTENT: Record<string, Intent> = {
|
||||||
@@ -25,92 +29,145 @@ const STATUS_INTENT: Record<string, Intent> = {
|
|||||||
Pending: Intent.NONE,
|
Pending: Intent.NONE,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Promotion terminal ────────────────────────────────────────────────────────
|
const BUILD_COLOR: Record<string, string> = {
|
||||||
|
Succeeded: '#1c6e42',
|
||||||
|
Failed: '#c23030',
|
||||||
|
Running: '#2d72d2',
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- BuildSparkline -----------------------------------------------------------
|
||||||
|
|
||||||
|
const MAX_BAR_H = 44;
|
||||||
|
const BAR_W = 6;
|
||||||
|
const BAR_GAP = 2;
|
||||||
|
const N_BARS = 20;
|
||||||
|
|
||||||
|
function BuildSparkline({ builds }: { builds: BuildHistoryRecord[] }) {
|
||||||
|
const recent = builds.slice(0, N_BARS).reverse();
|
||||||
|
const maxMs = Math.max(...recent.map((b) => b.durationMs ?? 0), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: BAR_GAP, height: MAX_BAR_H, marginTop: '0.4rem' }}>
|
||||||
|
{recent.map((b) => {
|
||||||
|
const h = b.durationMs ? Math.max(4, Math.round((b.durationMs / maxMs) * MAX_BAR_H)) : 4;
|
||||||
|
const color = BUILD_COLOR[b.status] ?? '#8f99a8';
|
||||||
|
const date = b.startedAt ? new Date(b.startedAt).toLocaleDateString() : '';
|
||||||
|
const dur = b.durationMs != null ? `${(b.durationMs / 1000).toFixed(1)}s` : '-';
|
||||||
|
const sha = b.commitSha ? b.commitSha.slice(0, 7) : '\u2014';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
title={`${sha} \u00b7 ${date} \u00b7 ${dur} \u00b7 ${b.status}`}
|
||||||
|
style={{
|
||||||
|
width: BAR_W,
|
||||||
|
height: h,
|
||||||
|
background: color,
|
||||||
|
borderRadius: 2,
|
||||||
|
flexShrink: 0,
|
||||||
|
cursor: 'default',
|
||||||
|
opacity: b.status === 'Running' ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- PromotionTerminal --------------------------------------------------------
|
||||||
|
|
||||||
function PromotionTerminal({ lines }: { lines: string[] }) {
|
function PromotionTerminal({ lines }: { lines: string[] }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]);
|
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={{
|
<div
|
||||||
fontFamily: 'Consolas, "Courier New", monospace',
|
ref={ref}
|
||||||
fontSize: '0.75rem',
|
style={{
|
||||||
lineHeight: 1.6,
|
fontFamily: 'Consolas, "Courier New", monospace',
|
||||||
background: '#0d1117',
|
fontSize: '0.75rem',
|
||||||
color: '#c9d1d9',
|
lineHeight: 1.6,
|
||||||
padding: '0.75rem 1rem',
|
background: '#0d1117',
|
||||||
borderRadius: 6,
|
color: '#c9d1d9',
|
||||||
height: 300,
|
padding: '0.75rem 1rem',
|
||||||
overflowY: 'auto',
|
borderRadius: 6,
|
||||||
whiteSpace: 'pre-wrap',
|
height: 220,
|
||||||
wordBreak: 'break-all',
|
overflowY: 'auto',
|
||||||
border: '1px solid #30363d',
|
whiteSpace: 'pre-wrap',
|
||||||
}}>
|
wordBreak: 'break-all',
|
||||||
{lines.length === 0
|
border: '1px solid #30363d',
|
||||||
? <span style={{ color: '#484f58' }}>Waiting for promotion output…</span>
|
}}
|
||||||
: lines.map((l, i) => {
|
>
|
||||||
const color = l.startsWith('✔') ? '#3fb950'
|
{lines.length === 0 ? (
|
||||||
: l.startsWith('✖') ? '#f85149'
|
<span style={{ color: '#484f58' }}>Waiting for output\u2026</span>
|
||||||
: l.startsWith('⚠') ? '#d29922'
|
) : (
|
||||||
: l.startsWith('──') ? '#484f58'
|
lines.map((l, i) => {
|
||||||
: undefined;
|
const color =
|
||||||
return <div key={i} style={color ? { color } : undefined}>{l}</div>;
|
l.startsWith('[ok]') ? '#3fb950' :
|
||||||
})
|
l.startsWith('[err]') ? '#f85149' :
|
||||||
}
|
l.startsWith('[warn]') ? '#d29922' :
|
||||||
|
l.startsWith('--') ? '#8f99a8' :
|
||||||
|
undefined;
|
||||||
|
return <div key={i} style={color ? { color } : undefined}>{l}</div>;
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Promote dialog ────────────────────────────────────────────────────────────
|
// -- PromoteDialog ------------------------------------------------------------
|
||||||
|
|
||||||
function PromoteDialog({
|
function PromoteDialog({
|
||||||
from, to, onClose, onDone,
|
from, to, repo, onClose, onDone,
|
||||||
}: {
|
}: {
|
||||||
from: string; to: string;
|
from: string; to: string; repo: RepoName;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [logs, setLogs] = useState<string[]>([]);
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const cancelRef = useRef<(() => void) | null>(null);
|
const cancelRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
const fromMeta = LADDER.find((l) => l.branch === from);
|
||||||
|
const toMeta = LADDER.find((l) => l.branch === to);
|
||||||
|
|
||||||
const handlePromote = () => {
|
const handlePromote = () => {
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
cancelRef.current = triggerPromotion(
|
cancelRef.current = triggerPromotion(
|
||||||
from, to, 'control-plane', note || undefined,
|
from, to, 'control-plane', note || undefined,
|
||||||
(line) => setLogs((p) => [...p, line]),
|
(line) => setLogs((p) => [...p, line]),
|
||||||
() => { setRunning(false); setDone(true); onDone(); },
|
(_record) => { setRunning(false); setDone(true); onDone(); },
|
||||||
(err) => { setError(err); setRunning(false); },
|
(err) => { setError(err); setRunning(false); },
|
||||||
|
repo,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => { cancelRef.current?.(); onClose(); };
|
||||||
cancelRef.current?.();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const fromLabel = LADDER.find((l) => l.branch === from)?.label ?? from;
|
|
||||||
const toLabel = LADDER.find((l) => l.branch === to)?.label ?? to;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen
|
isOpen
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
title={`Promote ${fromLabel} → ${toLabel}`}
|
title={
|
||||||
style={{ width: 640 }}
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<Tag intent={fromMeta?.intent} minimal style={{ fontFamily: 'monospace' }}>{from}</Tag>
|
||||||
|
<span style={{ color: '#8f99a8' }}>{'\u2192'}</span>
|
||||||
|
<Tag intent={toMeta?.intent} minimal style={{ fontFamily: 'monospace' }}>{to}</Tag>
|
||||||
|
<span style={{ color: '#8f99a8', fontSize: '0.8rem', marginLeft: '0.25rem' }}>[{repo}]</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
style={{ width: 580 }}
|
||||||
>
|
>
|
||||||
<DialogBody>
|
<DialogBody>
|
||||||
{!running && !done && (
|
{!running && !done && (
|
||||||
<>
|
<>
|
||||||
<p style={{ marginBottom: '0.75rem', color: '#8f99a8' }}>
|
<p style={{ marginBottom: '0.75rem', color: '#738091', fontSize: '0.85rem' }}>
|
||||||
This will merge <code>{from}</code> into <code>{to}</code> with a
|
Merges <code>{from}</code> into <code>{to}</code> via no-fast-forward and pushes to origin.
|
||||||
no-fast-forward commit and push to origin.
|
|
||||||
</p>
|
</p>
|
||||||
<TextArea
|
<TextArea
|
||||||
fill
|
fill
|
||||||
@@ -121,24 +178,20 @@ function PromoteDialog({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(running || logs.length > 0) && (
|
{(running || logs.length > 0) && (
|
||||||
<div style={{ marginTop: running && !logs.length ? 0 : '0.75rem' }}>
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
<PromotionTerminal lines={logs} />
|
<PromotionTerminal lines={logs} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Callout intent={Intent.DANGER} style={{ marginTop: '0.5rem' }}>{error}</Callout>
|
<Callout intent={Intent.DANGER} style={{ marginTop: '0.5rem' }}>{error}</Callout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{done && (
|
{done && (
|
||||||
<Callout intent={Intent.SUCCESS} icon="tick" style={{ marginTop: '0.5rem' }}>
|
<Callout intent={Intent.SUCCESS} icon="tick" style={{ marginTop: '0.5rem' }}>
|
||||||
Promotion complete. Tenants on <strong>{to}</strong> can now be released.
|
Promotion complete. Tenants on <strong>{to}</strong> can now be released.
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
|
|
||||||
<DialogFooter
|
<DialogFooter
|
||||||
minimal
|
minimal
|
||||||
actions={
|
actions={
|
||||||
@@ -150,7 +203,7 @@ function PromoteDialog({
|
|||||||
<Button
|
<Button
|
||||||
intent={Intent.WARNING}
|
intent={Intent.WARNING}
|
||||||
icon="arrow-right"
|
icon="arrow-right"
|
||||||
text={running ? 'Promoting…' : `Promote ${fromLabel} → ${toLabel}`}
|
text={running ? 'Promoting\u2026' : `Promote ${from} \u2192 ${to}`}
|
||||||
loading={running}
|
loading={running}
|
||||||
disabled={running}
|
disabled={running}
|
||||||
onClick={handlePromote}
|
onClick={handlePromote}
|
||||||
@@ -163,91 +216,336 @@ function PromoteDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Branch ladder card ────────────────────────────────────────────────────────
|
// -- ResetDialog -------------------------------------------------------------
|
||||||
|
|
||||||
function LadderCard({
|
function ResetDialog({
|
||||||
status, nextBranch, onPromote,
|
branch, toSha, prevBranch, repo, onClose, onDone,
|
||||||
}: {
|
}: {
|
||||||
status: BranchStatus;
|
branch: string;
|
||||||
nextBranch: string | null;
|
toSha: string;
|
||||||
onPromote: (from: string, to: string) => void;
|
prevBranch: string;
|
||||||
|
repo: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const meta = LADDER.find((l) => l.branch === status.branch)!;
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
setRunning(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await resetBranch(branch, toSha, repo);
|
||||||
|
onDone();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Reset failed');
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card elevation={Elevation.ONE} style={{ marginBottom: '0.75rem' }}>
|
<Dialog isOpen onClose={onClose} title="Reset branch" style={{ width: 480 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
<DialogBody>
|
||||||
|
<Callout intent={Intent.WARNING} icon="warning-sign" style={{ marginBottom: '1rem' }}>
|
||||||
{/* Branch name + env badge */}
|
<strong>{branch}</strong> has commits that are not in <strong>{prevBranch}</strong>.
|
||||||
<div style={{ flex: '0 0 auto', minWidth: 120 }}>
|
This is a diverged state — likely caused by a merge commit landing directly on {branch}.
|
||||||
<Tag intent={meta.intent} large minimal style={{ fontFamily: 'monospace', fontWeight: 600 }}>
|
</Callout>
|
||||||
{status.branch}
|
<p style={{ fontSize: '0.85rem', color: '#738091', marginBottom: '0.5rem' }}>
|
||||||
</Tag>
|
This will force-reset <code>{branch}</code> to{' '}
|
||||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: '#8f99a8' }}>
|
<code style={{ color: '#2d72d2' }}>{toSha}</code> (current {prevBranch} tip)
|
||||||
→ {meta.env}
|
and force-push to origin.
|
||||||
</span>
|
</p>
|
||||||
</div>
|
<p style={{ fontSize: '0.85rem', color: '#c23030' }}>
|
||||||
|
Any commits currently only on {branch} will be unreachable. Make sure they are
|
||||||
{/* Last commit */}
|
backported to <code>{prevBranch}</code> first if they need to be kept.
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
</p>
|
||||||
{status.exists ? (
|
{error && <Callout intent={Intent.DANGER} style={{ marginTop: '0.75rem' }}>{error}</Callout>}
|
||||||
<span style={{ fontSize: '0.8rem', color: '#8f99a8', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
|
</DialogBody>
|
||||||
<code style={{ color: '#4a90d9', marginRight: '0.4rem' }}>{status.shortHash}</code>
|
<DialogFooter
|
||||||
{status.lastCommitSummary}
|
minimal
|
||||||
</span>
|
actions={
|
||||||
) : (
|
<>
|
||||||
<span style={{ fontSize: '0.8rem', color: '#484f58' }}>Branch does not exist yet</span>
|
<Button text="Cancel" onClick={onClose} disabled={running} />
|
||||||
)}
|
<Button
|
||||||
</div>
|
intent={Intent.DANGER}
|
||||||
|
icon="reset"
|
||||||
{/* Ahead badge + unreleased toggle */}
|
text={running ? 'Resetting\u2026' : `Force-reset ${branch} \u2192 ${toSha}`}
|
||||||
{status.exists && status.aheadOfNext > 0 && nextBranch && (
|
loading={running}
|
||||||
<Button
|
disabled={running}
|
||||||
minimal small
|
onClick={handleReset}
|
||||||
intent={Intent.WARNING}
|
/>
|
||||||
icon={open ? 'chevron-up' : 'layers'}
|
</>
|
||||||
text={`${status.aheadOfNext} unreleased`}
|
}
|
||||||
onClick={() => setOpen((o) => !o)}
|
/>
|
||||||
/>
|
</Dialog>
|
||||||
)}
|
|
||||||
|
|
||||||
{status.exists && status.aheadOfNext === 0 && nextBranch && (
|
|
||||||
<Tag minimal intent={Intent.SUCCESS} icon="tick">In sync</Tag>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Promote button */}
|
|
||||||
{status.exists && nextBranch && (
|
|
||||||
<Button
|
|
||||||
intent={Intent.PRIMARY}
|
|
||||||
small
|
|
||||||
icon="arrow-right"
|
|
||||||
text={`Promote → ${nextBranch}`}
|
|
||||||
disabled={status.aheadOfNext === 0}
|
|
||||||
onClick={() => onPromote(status.branch, nextBranch)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Unreleased commit list */}
|
|
||||||
<Collapse isOpen={open && status.unreleasedLines.length > 0}>
|
|
||||||
<div style={{ marginTop: '0.75rem', paddingLeft: '0.5rem', borderLeft: '2px solid #30363d' }}>
|
|
||||||
{status.unreleasedLines.map((line, i) => {
|
|
||||||
const [hash, ...rest] = line.split(' ');
|
|
||||||
return (
|
|
||||||
<div key={i} style={{ fontSize: '0.78rem', marginBottom: '0.2rem' }}>
|
|
||||||
<code style={{ color: '#4a90d9', marginRight: '0.5rem' }}>{hash}</code>
|
|
||||||
<span style={{ color: '#c9d1d9' }}>{rest.join(' ')}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Collapse>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── History table ─────────────────────────────────────────────────────────────
|
// -- CherryPickDialog ---------------------------------------------------------
|
||||||
|
|
||||||
|
function CherryPickDialog({
|
||||||
|
shas, from, to, repo, onClose, onDone,
|
||||||
|
}: {
|
||||||
|
shas: string[];
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
repo: RepoName;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const cancelRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
const fromMeta = LADDER.find((l) => l.branch === from);
|
||||||
|
const toMeta = LADDER.find((l) => l.branch === to);
|
||||||
|
|
||||||
|
const handleRun = () => {
|
||||||
|
setRunning(true);
|
||||||
|
setLogs([]);
|
||||||
|
setError(null);
|
||||||
|
cancelRef.current = triggerCherryPick(
|
||||||
|
shas, from, to, 'control-plane', undefined,
|
||||||
|
(line) => setLogs((p) => [...p, line]),
|
||||||
|
(_record) => { setRunning(false); setDone(true); onDone(); },
|
||||||
|
(err) => { setError(err); setRunning(false); },
|
||||||
|
repo,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => { cancelRef.current?.(); onClose(); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen
|
||||||
|
onClose={handleClose}
|
||||||
|
title={
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<span style={{ color: '#8f99a8', fontSize: '0.8rem' }}>Cherry-pick</span>
|
||||||
|
<Tag intent={fromMeta?.intent} minimal style={{ fontFamily: 'monospace' }}>{from}</Tag>
|
||||||
|
<span style={{ color: '#8f99a8' }}>{'\u2192'}</span>
|
||||||
|
<Tag intent={toMeta?.intent} minimal style={{ fontFamily: 'monospace' }}>{to}</Tag>
|
||||||
|
<span style={{ color: '#8f99a8', fontSize: '0.8rem', marginLeft: '0.25rem' }}>[{repo}]</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
style={{ width: 580 }}
|
||||||
|
>
|
||||||
|
<DialogBody>
|
||||||
|
{!running && !done && (
|
||||||
|
<p style={{ marginBottom: '0.75rem', color: '#738091', fontSize: '0.85rem' }}>
|
||||||
|
Applies <strong>{shas.length}</strong> selected commit{shas.length > 1 ? 's' : ''} as new
|
||||||
|
commits on <code>{to}</code>. The branches will diverge — use the Reset button to
|
||||||
|
re-align if a full fast-forward promote is needed later.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(running || logs.length > 0) && (
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<PromotionTerminal lines={logs} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Callout intent={Intent.DANGER} style={{ marginTop: '0.5rem' }}>{error}</Callout>
|
||||||
|
)}
|
||||||
|
{done && (
|
||||||
|
<Callout intent={Intent.SUCCESS} icon="tick" style={{ marginTop: '0.5rem' }}>
|
||||||
|
Cherry-pick complete. <strong>{shas.length}</strong> commit{shas.length > 1 ? 's' : ''} applied to <strong>{to}</strong>.
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter
|
||||||
|
minimal
|
||||||
|
actions={
|
||||||
|
done ? (
|
||||||
|
<Button intent={Intent.SUCCESS} text="Close" onClick={handleClose} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button text="Cancel" onClick={handleClose} disabled={running} />
|
||||||
|
<Button
|
||||||
|
intent={Intent.PRIMARY}
|
||||||
|
icon="git-merge"
|
||||||
|
text={running ? 'Cherry-picking\u2026' : `Cherry-pick ${shas.length} commit${shas.length > 1 ? 's' : ''} \u2192 ${to}`}
|
||||||
|
loading={running}
|
||||||
|
disabled={running}
|
||||||
|
onClick={handleRun}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- LadderStep ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function LadderStep({
|
||||||
|
status, prevStatus, meta, nextBranch, isLast, onPromote, onReset, onCherryPick, builds,
|
||||||
|
}: {
|
||||||
|
status: BranchStatus | undefined;
|
||||||
|
prevStatus: BranchStatus | undefined;
|
||||||
|
meta: typeof LADDER[number];
|
||||||
|
nextBranch: string | null;
|
||||||
|
isLast: boolean;
|
||||||
|
onPromote: (from: string, to: string) => void;
|
||||||
|
onReset: (branch: string, toSha: string) => void;
|
||||||
|
onCherryPick: (shas: string[], from: string, to: string) => void;
|
||||||
|
builds?: BuildHistoryRecord[];
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedShas, setSelectedShas] = useState<Set<string>>(new Set());
|
||||||
|
const toggleSha = (sha: string) =>
|
||||||
|
setSelectedShas(prev => { const next = new Set(prev); if (next.has(sha)) next.delete(sha); else next.add(sha); return next; });
|
||||||
|
const exists = status?.exists ?? false;
|
||||||
|
|
||||||
|
// Diverged = this branch has commits not in the branch before it.
|
||||||
|
// prevStatus.behindNext > 0 means prevBranch is behind THIS branch, i.e. we have extra commits.
|
||||||
|
const isDiverged = (prevStatus?.behindNext ?? 0) > 0;
|
||||||
|
const prevTip = prevStatus?.shortHash ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="job-card"
|
||||||
|
style={{ borderLeft: `4px solid ${meta.color}`, opacity: exists ? 1 : 0.55 }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem' }}>
|
||||||
|
|
||||||
|
{/* Branch label */}
|
||||||
|
<div style={{ width: 100, flexShrink: 0 }}>
|
||||||
|
<Tag
|
||||||
|
intent={meta.intent}
|
||||||
|
minimal
|
||||||
|
large
|
||||||
|
style={{ fontFamily: 'monospace', fontWeight: 700, marginBottom: '0.2rem' }}
|
||||||
|
>
|
||||||
|
{meta.branch}
|
||||||
|
</Tag>
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#8f99a8', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
|
{meta.env}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Commit info + sparkline */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{exists && status ? (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: '0.8rem', lineHeight: 1.5, overflow: 'hidden' }}>
|
||||||
|
<code style={{ color: '#2d72d2', marginRight: '0.4rem' }}>{status.shortHash}</code>
|
||||||
|
<span style={{ color: '#738091' }}>{status.lastCommitSummary}</span>
|
||||||
|
</div>
|
||||||
|
{builds && builds.length > 0 && <BuildSparkline builds={builds} />}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: '0.8rem', color: '#8f99a8', fontStyle: 'italic' }}>No branch yet</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diverged / reset warning */}
|
||||||
|
{exists && isDiverged && prevTip && (
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
|
<Button
|
||||||
|
intent={Intent.DANGER}
|
||||||
|
small
|
||||||
|
icon="reset"
|
||||||
|
text="Diverged — Reset"
|
||||||
|
onClick={() => onReset(meta.branch, prevTip)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sync badge + promote button */}
|
||||||
|
{exists && nextBranch && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexShrink: 0 }}>
|
||||||
|
{(status?.aheadOfNext ?? 0) > 0 ? (
|
||||||
|
<Button
|
||||||
|
minimal
|
||||||
|
small
|
||||||
|
intent={Intent.WARNING}
|
||||||
|
icon={open ? 'chevron-up' : 'layers'}
|
||||||
|
text={`${status!.aheadOfNext} unreleased`}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tag minimal intent={Intent.SUCCESS} icon="tick" style={{ fontSize: '0.72rem' }}>
|
||||||
|
In sync
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
intent={Intent.PRIMARY}
|
||||||
|
small
|
||||||
|
icon="arrow-right"
|
||||||
|
text={`Promote \u2192 ${nextBranch}`}
|
||||||
|
disabled={(status?.aheadOfNext ?? 0) === 0}
|
||||||
|
onClick={() => onPromote(meta.branch, nextBranch)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live badge for main */}
|
||||||
|
{isLast && exists && (
|
||||||
|
<Tag minimal intent={Intent.SUCCESS} icon="cloud-upload" style={{ fontSize: '0.72rem', flexShrink: 0 }}>
|
||||||
|
Live
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unreleased commit list with cherry-pick selection */}
|
||||||
|
<Collapse isOpen={open && (status?.unreleasedCommits.length ?? 0) > 0}>
|
||||||
|
<div style={{
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
background: '#f6f7f9',
|
||||||
|
border: '1px solid #e5e8eb',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: '0.74rem',
|
||||||
|
}}>
|
||||||
|
{status?.unreleasedCommits.map((c: CommitInfo) => (
|
||||||
|
<div
|
||||||
|
key={c.sha}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
marginBottom: '0.1rem',
|
||||||
|
padding: '0.2rem 0.35rem',
|
||||||
|
borderRadius: 3,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: selectedShas.has(c.sha) ? '#ebf3fd' : 'transparent',
|
||||||
|
}}
|
||||||
|
onClick={() => toggleSha(c.sha)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedShas.has(c.sha)}
|
||||||
|
onChange={() => toggleSha(c.sha)}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
<code style={{ color: '#2d72d2', flexShrink: 0 }}>{c.shortSha}</code>
|
||||||
|
<span style={{ color: '#1c2127', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{c.message}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#8f99a8', flexShrink: 0, fontSize: '0.68rem' }}>{c.author}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{nextBranch && selectedShas.size > 0 && (
|
||||||
|
<div style={{ marginTop: '0.5rem', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', borderTop: '1px solid #e5e8eb', paddingTop: '0.5rem' }}>
|
||||||
|
<Button minimal small text="Clear" onClick={() => setSelectedShas(new Set())} />
|
||||||
|
<Button
|
||||||
|
intent={Intent.PRIMARY}
|
||||||
|
small
|
||||||
|
icon="git-merge"
|
||||||
|
text={`Cherry-pick ${selectedShas.size} \u2192 ${nextBranch}`}
|
||||||
|
onClick={() => onCherryPick([...selectedShas], meta.branch, nextBranch)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Collapse>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- PromotionHistoryTable ----------------------------------------------------
|
||||||
|
|
||||||
function PromotionHistoryTable({ records }: { records: PromotionRecord[] }) {
|
function PromotionHistoryTable({ records }: { records: PromotionRecord[] }) {
|
||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
@@ -256,7 +554,7 @@ function PromotionHistoryTable({ records }: { records: PromotionRecord[] }) {
|
|||||||
return <p style={{ color: '#8f99a8', fontSize: '0.85rem' }}>No promotions yet.</p>;
|
return <p style={{ color: '#8f99a8', fontSize: '0.85rem' }}>No promotions yet.</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HTMLTable style={{ width: '100%', fontSize: '0.8rem' }}>
|
<HTMLTable style={{ width: '100%', fontSize: '0.8rem' }} striped>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Promotion</th>
|
<th>Promotion</th>
|
||||||
@@ -269,49 +567,65 @@ function PromotionHistoryTable({ records }: { records: PromotionRecord[] }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{records.map((r) => (
|
{records.map((r) => (
|
||||||
<>
|
<React.Fragment key={r.id}>
|
||||||
<tr key={r.id}>
|
<tr>
|
||||||
<td style={{ fontFamily: 'monospace' }}>{r.fromBranch} → {r.toBranch}</td>
|
<td style={{ fontFamily: 'monospace' }}>{r.fromBranch} {'\u2192'} {r.toBranch}</td>
|
||||||
<td><Tag intent={STATUS_INTENT[r.status] ?? Intent.NONE} minimal round>{r.status}</Tag></td>
|
<td><Tag intent={STATUS_INTENT[r.status] ?? Intent.NONE} minimal round>{r.status}</Tag></td>
|
||||||
<td style={{ color: '#8f99a8' }}>{r.commitCount}</td>
|
<td style={{ color: '#8f99a8' }}>{r.commitCount}</td>
|
||||||
<td style={{ color: '#8f99a8' }}>{r.requestedBy}</td>
|
<td style={{ color: '#8f99a8' }}>{r.requestedBy}</td>
|
||||||
<td style={{ color: '#8f99a8' }}>{r.createdAt ? new Date(r.createdAt).toLocaleString() : '—'}</td>
|
<td style={{ color: '#8f99a8' }}>{r.createdAt ? new Date(r.createdAt).toLocaleString() : '\u2014'}</td>
|
||||||
<td>
|
<td>
|
||||||
<Button minimal small icon={expanded === r.id ? 'chevron-up' : 'chevron-down'}
|
<Button
|
||||||
onClick={() => setExpanded(expanded === r.id ? null : r.id)} />
|
minimal small
|
||||||
|
icon={expanded === r.id ? 'chevron-up' : 'chevron-down'}
|
||||||
|
onClick={() => setExpanded(expanded === r.id ? null : r.id)}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expanded === r.id && (
|
{expanded === r.id && (
|
||||||
<tr key={`${r.id}-log`}>
|
<tr>
|
||||||
<td colSpan={6} style={{ padding: '0.5rem 1rem' }}>
|
<td colSpan={6} style={{ padding: '0.5rem 1rem' }}>
|
||||||
{r.note && <p style={{ color: '#8f99a8', fontSize: '0.78rem', margin: '0 0 0.5rem' }}>Note: {r.note}</p>}
|
{r.note && (
|
||||||
|
<p style={{ color: '#8f99a8', fontSize: '0.78rem', margin: '0 0 0.5rem' }}>
|
||||||
|
Note: {r.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<PromotionTerminal lines={r.log} />
|
<PromotionTerminal lines={r.log} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</HTMLTable>
|
</HTMLTable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
// -- Page ---------------------------------------------------------------------
|
||||||
|
|
||||||
export default function BranchPage() {
|
export default function BranchPage() {
|
||||||
const [ladder, setLadder] = useState<BranchStatus[]>([]);
|
const [repo, setRepo] = useState<RepoName>('Clarity');
|
||||||
const [history, setHistory] = useState<PromotionRecord[]>([]);
|
const [ladder, setLadder] = useState<BranchStatus[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [history, setHistory] = useState<PromotionRecord[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [builds, setBuilds] = useState<BuildHistoryRecord[]>([]);
|
||||||
const [dialog, setDialog] = useState<{ from: string; to: string } | null>(null);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dialog, setDialog] = useState<{ from: string; to: string } | null>(null);
|
||||||
|
const [resetDialog, setResetDialog] = useState<{ branch: string; toSha: string } | null>(null);
|
||||||
|
const [cherryPickDialog, setCherryPickDialog] = useState<{ shas: string[]; from: string; to: string } | null>(null);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async (r: RepoName = repo) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [l, h] = await Promise.all([getLadderStatus(), getPromotionHistory()]);
|
const [l, h, b] = await Promise.all([
|
||||||
|
getLadderStatus(r),
|
||||||
|
getPromotionHistory(),
|
||||||
|
getImageBuildHistory(),
|
||||||
|
]);
|
||||||
setLadder(l);
|
setLadder(l);
|
||||||
setHistory(h);
|
setHistory(h);
|
||||||
|
setBuilds(b);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load');
|
setError(e instanceof Error ? e.message : 'Failed to load');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -319,16 +633,34 @@ export default function BranchPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { (async () => { await load(); })(); }, []);
|
useEffect(() => { load(); }, [repo]);
|
||||||
|
|
||||||
|
const handleRepoChange = (val: string) => {
|
||||||
|
setRepo(val as RepoName);
|
||||||
|
setLadder([]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Header */}
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Branch Ladder</h1>
|
<h1>Branch Ladder</h1>
|
||||||
<p>Promote code through <code>develop → staging → uat → main</code>. Developers merge to <code>develop</code>, Control Plane handles everything above.</p>
|
<p>
|
||||||
|
Promote code through{' '}
|
||||||
|
<code>develop {'\u2192'} staging {'\u2192'} uat {'\u2192'} main</code>.{' '}
|
||||||
|
Developers push to <code>develop</code> \u2014 Control Plane handles everything above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<SegmentedControl
|
||||||
|
options={REPOS.map((r) => ({ label: r, value: r }))}
|
||||||
|
value={repo}
|
||||||
|
onValueChange={handleRepoChange}
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
<Button icon="refresh" minimal onClick={() => load()} loading={loading} title="Refresh" />
|
||||||
</div>
|
</div>
|
||||||
<Button icon="refresh" minimal onClick={load} loading={loading} title="Refresh" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -337,45 +669,107 @@ export default function BranchPage() {
|
|||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && !ladder.length ? (
|
{/* Vertical ladder */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#8f99a8' }}>
|
<section style={{ marginBottom: '2rem', maxWidth: 860 }}>
|
||||||
<Spinner size={16} /> Loading branch status…
|
{loading && !ladder.length ? (
|
||||||
</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#8f99a8', padding: '1.5rem 0' }}>
|
||||||
) : (
|
<Spinner size={16} /> Loading branch status\u2026
|
||||||
<>
|
</div>
|
||||||
{/* ── Ladder ── */}
|
) : (
|
||||||
<section style={{ marginBottom: '2rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
{ladder.map((s, i) => (
|
{LADDER.map((meta, i) => {
|
||||||
<LadderCard
|
const status = ladder.find((s) => s.branch === meta.branch);
|
||||||
key={s.branch}
|
const prevStatus = i > 0 ? ladder.find((s) => s.branch === LADDER[i - 1].branch) : undefined;
|
||||||
status={s}
|
const nextBranch = i + 1 < LADDER.length ? LADDER[i + 1].branch : null;
|
||||||
nextBranch={i + 1 < LADDER.length ? LADDER[i + 1].branch : null}
|
const isLast = i === LADDER.length - 1;
|
||||||
onPromote={(from, to) => setDialog({ from, to })}
|
return (
|
||||||
/>
|
<div key={meta.branch}>
|
||||||
))}
|
<LadderStep
|
||||||
</section>
|
status={status}
|
||||||
|
prevStatus={prevStatus}
|
||||||
|
meta={meta}
|
||||||
|
nextBranch={nextBranch}
|
||||||
|
isLast={isLast}
|
||||||
|
onPromote={(from, to) => setDialog({ from, to })}
|
||||||
|
onReset={(branch, toSha) => setResetDialog({ branch, toSha })}
|
||||||
|
onCherryPick={(shas, from, to) => setCherryPickDialog({ shas, from, to })}
|
||||||
|
builds={meta.branch === 'develop' ? builds : undefined}
|
||||||
|
/>
|
||||||
|
{!isLast && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingLeft: '1.5rem',
|
||||||
|
height: 20,
|
||||||
|
color: '#c5cbd3',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}>
|
||||||
|
{'\u2193'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* ── History ── */}
|
{/* Promotion history */}
|
||||||
<section>
|
{!loading && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
<section>
|
||||||
<h3 style={{ margin: 0, fontSize: '0.9rem', color: '#8f99a8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
<h3 style={{
|
||||||
Promotion History
|
margin: '0 0 0.75rem',
|
||||||
</h3>
|
fontSize: '0.85rem',
|
||||||
</div>
|
color: '#738091',
|
||||||
<PromotionHistoryTable records={history} />
|
textTransform: 'uppercase',
|
||||||
</section>
|
letterSpacing: '0.06em',
|
||||||
</>
|
}}>
|
||||||
|
Promotion History
|
||||||
|
</h3>
|
||||||
|
<PromotionHistoryTable records={history} />
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Promotion dialog ── */}
|
{/* Promote dialog */}
|
||||||
{dialog && (
|
{dialog && (
|
||||||
<PromoteDialog
|
<PromoteDialog
|
||||||
from={dialog.from}
|
from={dialog.from}
|
||||||
to={dialog.to}
|
to={dialog.to}
|
||||||
|
repo={repo}
|
||||||
onClose={() => setDialog(null)}
|
onClose={() => setDialog(null)}
|
||||||
onDone={() => { setDialog(null); load(); }}
|
onDone={() => { setDialog(null); load(); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reset dialog */}
|
||||||
|
{resetDialog && (() => {
|
||||||
|
const branchIdx = LADDER.findIndex((l) => l.branch === resetDialog.branch);
|
||||||
|
const prevBranch = branchIdx > 0 ? LADDER[branchIdx - 1].branch : 'develop';
|
||||||
|
return (
|
||||||
|
<ResetDialog
|
||||||
|
branch={resetDialog.branch}
|
||||||
|
toSha={resetDialog.toSha}
|
||||||
|
prevBranch={prevBranch}
|
||||||
|
repo={repo}
|
||||||
|
onClose={() => setResetDialog(null)}
|
||||||
|
onDone={() => { setResetDialog(null); load(); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Cherry-pick dialog */}
|
||||||
|
{cherryPickDialog && (
|
||||||
|
<CherryPickDialog
|
||||||
|
shas={cherryPickDialog.shas}
|
||||||
|
from={cherryPickDialog.from}
|
||||||
|
to={cherryPickDialog.to}
|
||||||
|
repo={repo}
|
||||||
|
onClose={() => setCherryPickDialog(null)}
|
||||||
|
onDone={() => { setCherryPickDialog(null); load(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user