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);
|
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;
|
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 PromoteRequest(string From, string To, string? RequestedBy, string? Note, string? Repo);
|
||||||
public record ResetBranchRequest(string Branch, string ToSha, 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 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 ──────────────────────────────────────────────────
|
// ── History persistence ──────────────────────────────────────────────────
|
||||||
|
|
||||||
private string HistoryPath
|
private string HistoryPath
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace ControlPlane.Core.Models;
|
||||||
|
|
||||||
|
public enum ConformanceViolation { OK, Missing, Diverged, Stale }
|
||||||
|
public enum ConformanceSeverity { OK, Info, Warning, Critical }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The conformance state of one branch in the TBD ladder relative to its upstream source.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full TBD conformance report for a single repository.
|
||||||
|
/// IsConformant = no Diverged or Missing violations exist.
|
||||||
|
/// </summary>
|
||||||
|
public record ConformanceReport(
|
||||||
|
string Repo,
|
||||||
|
bool IsConformant,
|
||||||
|
BranchConformanceCheck[] Checks
|
||||||
|
);
|
||||||
@@ -378,3 +378,49 @@ export function triggerCherryPick(
|
|||||||
|
|
||||||
return () => { cancelled = true; controller.abort(); };
|
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<ConformanceReport> {
|
||||||
|
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<ConformanceReport[]> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@ import {
|
|||||||
} from '@blueprintjs/core';
|
} from '@blueprintjs/core';
|
||||||
import {
|
import {
|
||||||
getLadderStatus, getPromotionHistory, triggerPromotion, triggerCherryPick,
|
getLadderStatus, getPromotionHistory, triggerPromotion, triggerCherryPick,
|
||||||
getImageBuildHistory, resetBranch,
|
getImageBuildHistory, resetBranch, getAllConformanceReports, createLadderBranch,
|
||||||
type BranchStatus, type CommitInfo, type PromotionRecord, type BuildHistoryRecord,
|
type BranchStatus, type CommitInfo, type PromotionRecord, type BuildHistoryRecord,
|
||||||
|
type ConformanceReport, type BranchConformanceCheck,
|
||||||
} from '../api/provisioningApi';
|
} from '../api/provisioningApi';
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
@@ -601,9 +602,364 @@ function PromotionHistoryTable({ records }: { records: PromotionRecord[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- ConformancePage ----------------------------------------------------------
|
||||||
|
|
||||||
|
const SEV_INTENT: Record<string, Intent> = {
|
||||||
|
OK: Intent.SUCCESS,
|
||||||
|
Info: Intent.PRIMARY,
|
||||||
|
Warning: Intent.WARNING,
|
||||||
|
Critical: Intent.DANGER,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEV_ICON: Record<string, string> = {
|
||||||
|
OK: 'tick-circle',
|
||||||
|
Info: 'info-sign',
|
||||||
|
Warning: 'warning-sign',
|
||||||
|
Critical: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const VIOLATION_LABEL: Record<string, string> = {
|
||||||
|
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<string | null>(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 (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
intent={isDiverged ? Intent.DANGER : Intent.PRIMARY}
|
||||||
|
small
|
||||||
|
icon={isDiverged ? 'reset' : 'git-new-branch'}
|
||||||
|
text={isDiverged ? `Reset to ${check.sourceBranch}` : `Create from ${check.sourceBranch}`}
|
||||||
|
onClick={() => setDialog(true)}
|
||||||
|
/>
|
||||||
|
{dialog && (
|
||||||
|
<Dialog
|
||||||
|
isOpen
|
||||||
|
onClose={() => { if (!running) setDialog(false); }}
|
||||||
|
title={isDiverged ? 'Reset diverged branch' : 'Create missing branch'}
|
||||||
|
style={{ width: 480 }}
|
||||||
|
>
|
||||||
|
<DialogBody>
|
||||||
|
{isDiverged ? (
|
||||||
|
<>
|
||||||
|
<Callout intent={Intent.DANGER} icon="error" style={{ marginBottom: '1rem' }}>
|
||||||
|
<strong>{check.branch}</strong> has <strong>{check.aheadOfSource}</strong> commit(s) not reachable
|
||||||
|
from <strong>{check.sourceBranch}</strong>. This is a TBD violation.
|
||||||
|
</Callout>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: '#738091' }}>
|
||||||
|
Force-reset <code>{check.branch}</code> to{' '}
|
||||||
|
<code style={{ color: '#2d72d2' }}>{check.fixSha?.slice(0, 7)}</code>{' '}
|
||||||
|
(current <code>{check.sourceBranch}</code> tip) and force-push to origin.
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: '#c23030' }}>
|
||||||
|
Commits only on <code>{check.branch}</code> will become unreachable.
|
||||||
|
Backport them to <code>{check.sourceBranch}</code> first if needed.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Callout intent={Intent.PRIMARY} icon="info-sign" style={{ marginBottom: '1rem' }}>
|
||||||
|
Branch <strong>{check.branch}</strong> does not exist yet.
|
||||||
|
</Callout>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: '#738091' }}>
|
||||||
|
Create <code>{check.branch}</code> from{' '}
|
||||||
|
<code style={{ color: '#2d72d2' }}>{check.fixSha?.slice(0, 7)}</code>{' '}
|
||||||
|
(current <code>{check.sourceBranch}</code> tip) and push to origin.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && <Callout intent={Intent.DANGER} style={{ marginTop: '0.75rem' }}>{error}</Callout>}
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter
|
||||||
|
minimal
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button text="Cancel" onClick={() => setDialog(false)} disabled={running} />
|
||||||
|
<Button
|
||||||
|
intent={isDiverged ? Intent.DANGER : Intent.PRIMARY}
|
||||||
|
icon={isDiverged ? 'reset' : 'git-new-branch'}
|
||||||
|
text={running ? 'Working…' : isDiverged ? `Force-reset ${check.branch}` : `Create ${check.branch}`}
|
||||||
|
loading={running}
|
||||||
|
disabled={running}
|
||||||
|
onClick={handleFix}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConformanceRepoCard({
|
||||||
|
report,
|
||||||
|
onFixed,
|
||||||
|
}: {
|
||||||
|
report: ConformanceReport;
|
||||||
|
onFixed: () => void;
|
||||||
|
}) {
|
||||||
|
const criticalCount = report.checks.filter((c) => c.severity === 'Critical').length;
|
||||||
|
const warningCount = report.checks.filter((c) => c.severity === 'Warning').length;
|
||||||
|
const infoCount = report.checks.filter((c) => c.severity === 'Info').length;
|
||||||
|
|
||||||
|
const headerIntent = criticalCount > 0 ? Intent.DANGER
|
||||||
|
: warningCount > 0 ? Intent.WARNING
|
||||||
|
: infoCount > 0 ? Intent.PRIMARY
|
||||||
|
: Intent.SUCCESS;
|
||||||
|
|
||||||
|
const headerText = criticalCount > 0
|
||||||
|
? `${criticalCount} critical violation${criticalCount > 1 ? 's' : ''}`
|
||||||
|
: warningCount > 0
|
||||||
|
? `${warningCount} warning${warningCount > 1 ? 's' : ''}`
|
||||||
|
: report.isConformant ? 'Fully conformant' : 'Attention needed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="job-card" style={{ marginBottom: '1.25rem' }}>
|
||||||
|
{/* Repo header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||||
|
<Tag
|
||||||
|
intent={headerIntent}
|
||||||
|
large
|
||||||
|
style={{ fontFamily: 'monospace', fontWeight: 700, letterSpacing: '0.04em' }}
|
||||||
|
>
|
||||||
|
{report.repo}
|
||||||
|
</Tag>
|
||||||
|
<Tag intent={headerIntent} minimal round icon={report.isConformant ? 'tick-circle' : 'error'}>
|
||||||
|
{headerText}
|
||||||
|
</Tag>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
{report.isConformant && (
|
||||||
|
<Tag intent={Intent.SUCCESS} minimal icon="endorsed" style={{ fontSize: '0.75rem' }}>
|
||||||
|
TBD Conformant
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-branch checks */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{report.checks.map((check) => (
|
||||||
|
<div
|
||||||
|
key={check.branch}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
background: check.severity === 'OK' ? '#f6f7f9'
|
||||||
|
: check.severity === 'Critical' ? '#fff0f0'
|
||||||
|
: check.severity === 'Warning' ? '#fffbf0'
|
||||||
|
: '#f0f6ff',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: `1px solid ${
|
||||||
|
check.severity === 'Critical' ? '#f5c6cb'
|
||||||
|
: check.severity === 'Warning' ? '#fde68a'
|
||||||
|
: check.severity === 'Info' ? '#bfdbfe'
|
||||||
|
: '#e5e8eb'
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Branch name */}
|
||||||
|
<Tag
|
||||||
|
intent={LADDER.find((l) => l.branch === check.branch)?.intent ?? Intent.NONE}
|
||||||
|
minimal
|
||||||
|
style={{ fontFamily: 'monospace', fontWeight: 600, flexShrink: 0, minWidth: 72, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
{check.branch}
|
||||||
|
</Tag>
|
||||||
|
|
||||||
|
{/* Violation badge */}
|
||||||
|
<Tag
|
||||||
|
intent={SEV_INTENT[check.severity]}
|
||||||
|
icon={SEV_ICON[check.severity] as any}
|
||||||
|
minimal
|
||||||
|
style={{ flexShrink: 0, fontSize: '0.72rem' }}
|
||||||
|
>
|
||||||
|
{VIOLATION_LABEL[check.violation]}
|
||||||
|
</Tag>
|
||||||
|
|
||||||
|
{/* Detail */}
|
||||||
|
<span style={{ flex: 1, fontSize: '0.8rem', color: '#4a5568', lineHeight: 1.4 }}>
|
||||||
|
{check.detail}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{check.violation !== 'OK' && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.4rem', flexShrink: 0, fontSize: '0.72rem' }}>
|
||||||
|
{check.aheadOfSource > 0 && (
|
||||||
|
<Tag minimal intent={Intent.DANGER} style={{ fontSize: '0.68rem' }}>
|
||||||
|
+{check.aheadOfSource} extra
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{check.behindSource > 0 && (
|
||||||
|
<Tag minimal intent={Intent.WARNING} style={{ fontSize: '0.68rem' }}>
|
||||||
|
-{check.behindSource} pending
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fix button */}
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
|
<ConformanceFixButton check={check} repo={report.repo} onDone={onFixed} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConformancePage() {
|
||||||
|
const [reports, setReports] = useState<ConformanceReport[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await getAllConformanceReports();
|
||||||
|
setReports(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load conformance data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const totalCritical = reports.reduce((n, r) => n + r.checks.filter((c) => c.severity === 'Critical').length, 0);
|
||||||
|
const allConformant = reports.length > 0 && reports.every((r) => r.isConformant);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Summary banner */}
|
||||||
|
{!loading && reports.length > 0 && (
|
||||||
|
<Callout
|
||||||
|
intent={allConformant ? Intent.SUCCESS : totalCritical > 0 ? Intent.DANGER : Intent.WARNING}
|
||||||
|
icon={allConformant ? 'endorsed' : totalCritical > 0 ? 'error' : 'warning-sign'}
|
||||||
|
style={{ marginBottom: '1.5rem' }}
|
||||||
|
>
|
||||||
|
{allConformant ? (
|
||||||
|
<span>All repositories are <strong>fully conformant</strong> with the TBD strategy.</span>
|
||||||
|
) : totalCritical > 0 ? (
|
||||||
|
<span>
|
||||||
|
<strong>{totalCritical} critical violation{totalCritical > 1 ? 's' : ''}</strong> found
|
||||||
|
across {reports.filter((r) => !r.isConformant).length} repo(s).
|
||||||
|
Use the fix actions below to restore TBD conformance.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>No critical violations — some branches have pending promotions or warnings.</span>
|
||||||
|
)}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Callout intent={Intent.DANGER} title="Failed to load conformance data" style={{ marginBottom: '1rem' }}>
|
||||||
|
{error}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#8f99a8', padding: '1.5rem 0' }}>
|
||||||
|
<Spinner size={16} /> Checking all repos for TBD conformance…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ maxWidth: 900 }}>
|
||||||
|
{reports.map((report) => (
|
||||||
|
<ConformanceRepoCard key={report.repo} report={report} onFixed={load} />
|
||||||
|
))}
|
||||||
|
{reports.length === 0 && (
|
||||||
|
<Callout intent={Intent.WARNING} icon="warning-sign">
|
||||||
|
No repositories are configured. Check <code>Git:Repos:*</code> in appsettings.
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
{!loading && reports.length > 0 && (
|
||||||
|
<section style={{ marginTop: '1.5rem', maxWidth: 900 }}>
|
||||||
|
<h3 style={{ margin: '0 0 0.75rem', fontSize: '0.8rem', color: '#738091', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
|
Violation Reference
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||||
|
{[
|
||||||
|
{ sev: 'Critical', viol: 'Diverged', desc: 'Branch has commits not in its source — breaks TBD linear history. Fix: force-reset to source tip.' },
|
||||||
|
{ sev: 'Info', viol: 'Missing', desc: 'Branch does not exist yet. Fix: create from source tip.' },
|
||||||
|
{ sev: 'Warning', viol: 'Stale', desc: 'Source has many unreleased commits (>10). Fix: promote source into this branch.' },
|
||||||
|
{ sev: 'Info', viol: 'Stale', desc: 'Source has unreleased commits — normal TBD state, pending promotion.' },
|
||||||
|
{ sev: 'OK', viol: 'OK', desc: 'Branch is fully in sync with its source branch.' },
|
||||||
|
].map((entry) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.sev}-${entry.viol}`}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
background: '#f6f7f9',
|
||||||
|
border: '1px solid #e5e8eb',
|
||||||
|
borderRadius: 6,
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
maxWidth: 260,
|
||||||
|
flex: '1 0 200px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag intent={SEV_INTENT[entry.sev]} minimal style={{ fontSize: '0.68rem', flexShrink: 0, marginTop: 2 }}>
|
||||||
|
{entry.viol}
|
||||||
|
</Tag>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#4a5568', lineHeight: 1.45 }}>{entry.desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -- Page ---------------------------------------------------------------------
|
// -- Page ---------------------------------------------------------------------
|
||||||
|
|
||||||
export default function BranchPage() {
|
export default function BranchPage() {
|
||||||
|
const [subNav, setSubNav] = useState<'ladder' | 'conformance'>('ladder');
|
||||||
const [repo, setRepo] = useState<RepoName>('Clarity');
|
const [repo, setRepo] = useState<RepoName>('Clarity');
|
||||||
const [ladder, setLadder] = useState<BranchStatus[]>([]);
|
const [ladder, setLadder] = useState<BranchStatus[]>([]);
|
||||||
const [history, setHistory] = useState<PromotionRecord[]>([]);
|
const [history, setHistory] = useState<PromotionRecord[]>([]);
|
||||||
@@ -653,16 +1009,42 @@ export default function BranchPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
{subNav === 'ladder' && (
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
options={REPOS.map((r) => ({ label: r, value: r }))}
|
options={REPOS.map((r) => ({ label: r, value: r }))}
|
||||||
value={repo}
|
value={repo}
|
||||||
onValueChange={handleRepoChange}
|
onValueChange={handleRepoChange}
|
||||||
small
|
small
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{subNav === 'ladder' && (
|
||||||
<Button icon="refresh" minimal onClick={() => load()} loading={loading} title="Refresh" />
|
<Button icon="refresh" minimal onClick={() => load()} loading={loading} title="Refresh" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-nav */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.5rem', borderBottom: '1px solid #e5e8eb', paddingBottom: '0.75rem' }}>
|
||||||
|
{([
|
||||||
|
{ id: 'ladder', label: 'Branch Ladder', icon: 'git-merge' },
|
||||||
|
{ id: 'conformance', label: 'Conformance', icon: 'endorsed' },
|
||||||
|
] as const).map((tab) => (
|
||||||
|
<Button
|
||||||
|
key={tab.id}
|
||||||
|
minimal
|
||||||
|
icon={tab.icon as any}
|
||||||
|
text={tab.label}
|
||||||
|
active={subNav === tab.id}
|
||||||
|
onClick={() => setSubNav(tab.id)}
|
||||||
|
style={{ fontWeight: subNav === tab.id ? 600 : 400 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subNav === 'conformance' && <ConformancePage />}
|
||||||
|
|
||||||
|
{subNav === 'ladder' && (
|
||||||
|
<>
|
||||||
{error && (
|
{error && (
|
||||||
<Callout intent={Intent.DANGER} title="Failed to load branch status" style={{ marginBottom: '1rem' }}>
|
<Callout intent={Intent.DANGER} title="Failed to load branch status" style={{ marginBottom: '1rem' }}>
|
||||||
{error}
|
{error}
|
||||||
@@ -771,5 +1153,7 @@ export default function BranchPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user