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
|
||||
|
||||
@@ -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(); };
|
||||
}
|
||||
|
||||
// -- 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';
|
||||
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<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 ---------------------------------------------------------------------
|
||||
|
||||
export default function BranchPage() {
|
||||
const [subNav, setSubNav] = useState<'ladder' | 'conformance'>('ladder');
|
||||
const [repo, setRepo] = useState<RepoName>('Clarity');
|
||||
const [ladder, setLadder] = useState<BranchStatus[]>([]);
|
||||
const [history, setHistory] = useState<PromotionRecord[]>([]);
|
||||
@@ -653,16 +1009,42 @@ export default function BranchPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
{subNav === 'ladder' && (
|
||||
<SegmentedControl
|
||||
options={REPOS.map((r) => ({ label: r, value: r }))}
|
||||
value={repo}
|
||||
onValueChange={handleRepoChange}
|
||||
small
|
||||
/>
|
||||
)}
|
||||
{subNav === 'ladder' && (
|
||||
<Button icon="refresh" minimal onClick={() => load()} loading={loading} title="Refresh" />
|
||||
)}
|
||||
</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 && (
|
||||
<Callout intent={Intent.DANGER} title="Failed to load branch status" style={{ marginBottom: '1rem' }}>
|
||||
{error}
|
||||
@@ -771,5 +1153,7 @@ export default function BranchPage() {
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user