From b26cc1c0b6545fe193193c7b05b71484f02dbba0 Mon Sep 17 00:00:00 2001 From: amadzarak Date: Sun, 26 Apr 2026 00:38:10 -0400 Subject: [PATCH] OPC # 0006: OPC Git Trunk-Based management Co-authored-by: Copilot --- .../Endpoints/PromotionEndpoints.cs | 39 ++ ControlPlane.Api/Services/PromotionService.cs | 153 +++++++ ControlPlane.Core/Models/ConformanceModels.cs | 28 ++ .../src/api/provisioningApi.ts | 46 ++ clarity.controlplane/src/pages/BranchPage.tsx | 412 +++++++++++++++++- 5 files changed, 664 insertions(+), 14 deletions(-) create mode 100644 ControlPlane.Core/Models/ConformanceModels.cs diff --git a/ControlPlane.Api/Endpoints/PromotionEndpoints.cs b/ControlPlane.Api/Endpoints/PromotionEndpoints.cs index 0414469..7084693 100644 --- a/ControlPlane.Api/Endpoints/PromotionEndpoints.cs +++ b/ControlPlane.Api/Endpoints/PromotionEndpoints.cs @@ -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); diff --git a/ControlPlane.Api/Services/PromotionService.cs b/ControlPlane.Api/Services/PromotionService.cs index 151a5e7..cfa0fa7 100644 --- a/ControlPlane.Api/Services/PromotionService.cs +++ b/ControlPlane.Api/Services/PromotionService.cs @@ -521,6 +521,159 @@ public class PromotionService(IConfiguration config, ILogger l } } + // ── Conformance check ──────────────────────────────────────────────────── + + /// + /// 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. + /// + public Task 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(); + + 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 ───────────────────────────────────────────────────────── + + /// + /// 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. + /// + 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(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 diff --git a/ControlPlane.Core/Models/ConformanceModels.cs b/ControlPlane.Core/Models/ConformanceModels.cs new file mode 100644 index 0000000..2f07a2e --- /dev/null +++ b/ControlPlane.Core/Models/ConformanceModels.cs @@ -0,0 +1,28 @@ +namespace ControlPlane.Core.Models; + +public enum ConformanceViolation { OK, Missing, Diverged, Stale } +public enum ConformanceSeverity { OK, Info, Warning, Critical } + +/// +/// The conformance state of one branch in the TBD ladder relative to its upstream source. +/// +public record BranchConformanceCheck( + string Branch, + string? SourceBranch, // the upstream branch this is derived from (null for trunk) + ConformanceViolation Violation, + ConformanceSeverity Severity, + string Detail, + int AheadOfSource, // commits this branch has that source doesn't — diverged + int BehindSource, // commits source has that this branch doesn't — pending promotion + string? FixSha // source tip SHA — used when resetting to fix divergence +); + +/// +/// Full TBD conformance report for a single repository. +/// IsConformant = no Diverged or Missing violations exist. +/// +public record ConformanceReport( + string Repo, + bool IsConformant, + BranchConformanceCheck[] Checks +); diff --git a/clarity.controlplane/src/api/provisioningApi.ts b/clarity.controlplane/src/api/provisioningApi.ts index 10280b3..0d2e20f 100644 --- a/clarity.controlplane/src/api/provisioningApi.ts +++ b/clarity.controlplane/src/api/provisioningApi.ts @@ -378,3 +378,49 @@ export function triggerCherryPick( return () => { cancelled = true; controller.abort(); }; } + +// -- Branch Conformance API -------------------------------------------------- + +export type ConformanceViolation = 'OK' | 'Missing' | 'Diverged' | 'Stale'; +export type ConformanceSeverity = 'OK' | 'Info' | 'Warning' | 'Critical'; + +export interface BranchConformanceCheck { + branch: string; + sourceBranch: string | null; + violation: ConformanceViolation; + severity: ConformanceSeverity; + detail: string; + aheadOfSource: number; + behindSource: number; + fixSha: string | null; +} + +export interface ConformanceReport { + repo: string; + isConformant: boolean; + checks: BranchConformanceCheck[]; +} + +export async function getConformanceReport(repo = 'Clarity'): Promise { + const res = await fetch(`${BASE_URL}/api/promotions/conformance?repo=${encodeURIComponent(repo)}`); + if (!res.ok) throw new Error(`Failed to get conformance report: ${res.statusText}`); + return res.json(); +} + +export async function getAllConformanceReports(): Promise { + const res = await fetch(`${BASE_URL}/api/promotions/conformance/all`); + if (!res.ok) throw new Error(`Failed to get conformance reports: ${res.statusText}`); + return res.json(); +} + +export async function createLadderBranch(branch: string, fromSha: string, repo: string): Promise { + const res = await fetch(`${BASE_URL}/api/promotions/create-branch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch, fromSha, repo }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error ?? res.statusText); + } +} \ No newline at end of file diff --git a/clarity.controlplane/src/pages/BranchPage.tsx b/clarity.controlplane/src/pages/BranchPage.tsx index 3e20267..ec1aef1 100644 --- a/clarity.controlplane/src/pages/BranchPage.tsx +++ b/clarity.controlplane/src/pages/BranchPage.tsx @@ -6,8 +6,9 @@ import { } from '@blueprintjs/core'; import { getLadderStatus, getPromotionHistory, triggerPromotion, triggerCherryPick, - getImageBuildHistory, resetBranch, + getImageBuildHistory, resetBranch, getAllConformanceReports, createLadderBranch, type BranchStatus, type CommitInfo, type PromotionRecord, type BuildHistoryRecord, + type ConformanceReport, type BranchConformanceCheck, } from '../api/provisioningApi'; // ── Constants ───────────────────────────────────────────────────────────────── @@ -601,9 +602,364 @@ function PromotionHistoryTable({ records }: { records: PromotionRecord[] }) { ); } +// -- ConformancePage ---------------------------------------------------------- + +const SEV_INTENT: Record = { + OK: Intent.SUCCESS, + Info: Intent.PRIMARY, + Warning: Intent.WARNING, + Critical: Intent.DANGER, +}; + +const SEV_ICON: Record = { + OK: 'tick-circle', + Info: 'info-sign', + Warning: 'warning-sign', + Critical: 'error', +}; + +const VIOLATION_LABEL: Record = { + OK: 'Conformant', + Missing: 'Missing', + Diverged: 'Diverged', + Stale: 'Stale', +}; + +function ConformanceFixButton({ + check, + repo, + onDone, +}: { + check: BranchConformanceCheck; + repo: string; + onDone: () => void; +}) { + const [running, setRunning] = useState(false); + const [error, setError] = useState(null); + const [dialog, setDialog] = useState(false); + + const handleFix = async () => { + setRunning(true); + setError(null); + try { + if (check.violation === 'Diverged' && check.fixSha) { + await resetBranch(check.branch, check.fixSha, repo); + } else if (check.violation === 'Missing' && check.fixSha) { + await createLadderBranch(check.branch, check.fixSha, repo); + } + setDialog(false); + onDone(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Fix failed'); + setRunning(false); + } + }; + + if (check.violation === 'OK' || check.violation === 'Stale') return null; + + const isDiverged = check.violation === 'Diverged'; + + return ( + <> +