OPC # 0006: OPC Git Trunk-Based management
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -140,6 +140,44 @@ public static class PromotionEndpoints
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
});
|
||||
|
||||
// GET /api/promotions/conformance?repo=Clarity
|
||||
// Returns a full TBD conformance report: which branches are diverged, missing, or stale.
|
||||
g.MapGet("/conformance", async (PromotionService svc, CancellationToken ct, string repo = "Clarity") =>
|
||||
Results.Ok(await svc.GetConformanceAsync(repo, ct)));
|
||||
|
||||
// GET /api/promotions/conformance/all
|
||||
// Returns conformance reports for all configured repos (Clarity, OPC, Gateway).
|
||||
g.MapGet("/conformance/all", async (PromotionService svc, IConfiguration config, CancellationToken ct) =>
|
||||
{
|
||||
var allRepos = new[] { "Clarity", "OPC", "Gateway" };
|
||||
var configured = allRepos
|
||||
.Where(r => !string.IsNullOrWhiteSpace(config[$"Git:Repos:{r}"]))
|
||||
.ToArray();
|
||||
|
||||
var tasks = configured.Select(r => svc.GetConformanceAsync(r, ct));
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return Results.Ok(results);
|
||||
});
|
||||
|
||||
// POST /api/promotions/create-branch — body: { branch, fromSha, repo }
|
||||
// Creates a missing ladder branch at the given SHA and pushes to origin.
|
||||
g.MapPost("/create-branch", async (PromotionService svc, CreateLadderBranchRequest req, CancellationToken ct) =>
|
||||
{
|
||||
var allowed = new[] { "staging", "uat", "main" };
|
||||
if (!allowed.Contains(req.Branch))
|
||||
return Results.BadRequest(new { error = $"Create-branch is only allowed for: {string.Join(", ", allowed)}." });
|
||||
|
||||
try
|
||||
{
|
||||
await svc.CreateBranchAsync(req.Branch, req.FromSha, req.Repo ?? "Clarity", ct);
|
||||
return Results.Ok(new { created = req.Branch, fromSha = req.FromSha });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -147,3 +185,4 @@ public static class PromotionEndpoints
|
||||
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);
|
||||
public record CreateLadderBranchRequest(string Branch, string FromSha, string? Repo);
|
||||
|
||||
@@ -521,6 +521,159 @@ public class PromotionService(IConfiguration config, ILogger<PromotionService> l
|
||||
}
|
||||
}
|
||||
|
||||
// ── Conformance check ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether all branches in the TBD ladder are in conformance:
|
||||
/// develop → staging → uat → main must form a strict linear ancestry chain with no divergence.
|
||||
/// </summary>
|
||||
public Task<ConformanceReport> GetConformanceAsync(string repoName = "Clarity", CancellationToken ct = default) =>
|
||||
Task.Run(() => GetConformanceCore(repoName, ct), ct);
|
||||
|
||||
private ConformanceReport GetConformanceCore(string repoName, CancellationToken ct)
|
||||
{
|
||||
var repoPath = GetRepoPath(repoName);
|
||||
var checks = new List<BranchConformanceCheck>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(repoPath) || !Directory.Exists(repoPath))
|
||||
{
|
||||
foreach (var b in Ladder)
|
||||
checks.Add(new BranchConformanceCheck(b, null, ConformanceViolation.Missing, ConformanceSeverity.Critical,
|
||||
$"Repository '{repoName}' is not configured or the path does not exist.", 0, 0, null));
|
||||
return new ConformanceReport(repoName, false, checks.ToArray());
|
||||
}
|
||||
|
||||
using var repo = new Repository(repoPath);
|
||||
|
||||
// Fetch latest 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 conformance check failed — continuing with cached refs");
|
||||
}
|
||||
|
||||
for (var i = 0; i < Ladder.Length; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var branchName = Ladder[i];
|
||||
var srcName = i > 0 ? Ladder[i - 1] : null; // predecessor branch (e.g. develop for staging)
|
||||
var branch = repo.Branches[branchName];
|
||||
|
||||
// ── Branch missing ──────────────────────────────────────────────
|
||||
if (branch?.Tip is null)
|
||||
{
|
||||
var srcTip = srcName is not null ? repo.Branches[srcName]?.Tip?.Sha : null;
|
||||
checks.Add(new BranchConformanceCheck(
|
||||
branchName, srcName,
|
||||
ConformanceViolation.Missing,
|
||||
srcName is null ? ConformanceSeverity.Critical : ConformanceSeverity.Info,
|
||||
srcName is not null
|
||||
? $"Branch '{branchName}' does not exist. It should be created from '{srcName}'."
|
||||
: $"Trunk branch '{branchName}' does not exist — the repository may be empty.",
|
||||
0, 0, srcTip));
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Trunk (develop) — just needs to exist ───────────────────────
|
||||
if (srcName is null)
|
||||
{
|
||||
checks.Add(new BranchConformanceCheck(
|
||||
branchName, null, ConformanceViolation.OK, ConformanceSeverity.OK,
|
||||
$"Trunk branch '{branchName}' exists.", 0, 0, null));
|
||||
continue;
|
||||
}
|
||||
|
||||
var srcBranch = repo.Branches[srcName];
|
||||
if (srcBranch?.Tip is null)
|
||||
{
|
||||
// Source branch is itself missing — skip, it will be reported separately.
|
||||
checks.Add(new BranchConformanceCheck(
|
||||
branchName, srcName, ConformanceViolation.OK, ConformanceSeverity.OK,
|
||||
$"Source branch '{srcName}' is missing — check skipped.", 0, 0, null));
|
||||
continue;
|
||||
}
|
||||
|
||||
// CalculateHistoryDivergence(srcTip, branchTip):
|
||||
// AheadBy = commits srcBranch has that branch doesn't → branch is pending promotion (stale)
|
||||
// BehindBy = commits branch has that srcBranch doesn't → branch is DIVERGED (violation)
|
||||
var div = repo.ObjectDatabase.CalculateHistoryDivergence(srcBranch.Tip, branch.Tip);
|
||||
var ahead = div.AheadBy ?? 0;
|
||||
var behind = div.BehindBy ?? 0;
|
||||
|
||||
if (behind > 0)
|
||||
{
|
||||
// Downstream has commits the upstream doesn't — TBD violation (broken linear history).
|
||||
checks.Add(new BranchConformanceCheck(
|
||||
branchName, srcName,
|
||||
ConformanceViolation.Diverged, ConformanceSeverity.Critical,
|
||||
$"'{branchName}' has {behind} commit(s) not reachable from '{srcName}'. " +
|
||||
$"This breaks TBD linear history. Likely caused by a commit made directly to '{branchName}' " +
|
||||
$"without backporting to trunk. Fix: reset '{branchName}' to '{srcName}' tip.",
|
||||
behind, ahead,
|
||||
srcBranch.Tip.Sha));
|
||||
}
|
||||
else if (ahead > 0)
|
||||
{
|
||||
// Upstream has unreleased commits — normal TBD state, but flag if count is high.
|
||||
var sev = ahead > 10 ? ConformanceSeverity.Warning : ConformanceSeverity.Info;
|
||||
checks.Add(new BranchConformanceCheck(
|
||||
branchName, srcName,
|
||||
ConformanceViolation.Stale, sev,
|
||||
$"'{branchName}' is {ahead} commit(s) behind '{srcName}'. " +
|
||||
(ahead > 10 ? "Large backlog — consider promoting soon." : "Pending promotion."),
|
||||
0, ahead, null));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new BranchConformanceCheck(
|
||||
branchName, srcName, ConformanceViolation.OK, ConformanceSeverity.OK,
|
||||
$"'{branchName}' is fully in sync with '{srcName}'.", 0, 0, null));
|
||||
}
|
||||
}
|
||||
|
||||
var isConformant = !checks.Any(c =>
|
||||
c.Violation is ConformanceViolation.Diverged or ConformanceViolation.Missing);
|
||||
|
||||
return new ConformanceReport(repoName, isConformant, checks.ToArray());
|
||||
}
|
||||
|
||||
// ── Create branch ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new branch at the given commit SHA and pushes it to origin.
|
||||
/// Used to create missing ladder branches (e.g. staging, uat) from their source branch tip.
|
||||
/// </summary>
|
||||
public Task CreateBranchAsync(string branchName, string fromSha, string repoName, CancellationToken ct) =>
|
||||
Task.Run(() =>
|
||||
{
|
||||
var repoPath = GetRepoPath(repoName);
|
||||
using var repo = new Repository(repoPath);
|
||||
|
||||
if (repo.Branches[branchName] is not null)
|
||||
throw new InvalidOperationException($"Branch '{branchName}' already exists in {repoName}.");
|
||||
|
||||
var commit = repo.Lookup<Commit>(fromSha)
|
||||
?? throw new InvalidOperationException($"SHA '{fromSha}' not found in {repoName}.");
|
||||
|
||||
repo.Refs.Add($"refs/heads/{branchName}", commit.Sha);
|
||||
|
||||
var remote = repo.Network.Remotes["origin"]
|
||||
?? throw new InvalidOperationException("No 'origin' remote configured.");
|
||||
|
||||
repo.Network.Push(remote, $"refs/heads/{branchName}:refs/heads/{branchName}", MakePushOptions());
|
||||
|
||||
logger.LogInformation("Created branch {Branch} at {Sha} in {Repo}", branchName, commit.Sha[..7], repoName);
|
||||
}, ct);
|
||||
|
||||
// ── History persistence ──────────────────────────────────────────────────
|
||||
|
||||
private string HistoryPath
|
||||
|
||||
Reference in New Issue
Block a user