OPC # 0006: OPC Git Trunk-Based management

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
amadzarak
2026-04-26 00:38:10 -04:00
parent db025cce01
commit b26cc1c0b6
5 changed files with 664 additions and 14 deletions
@@ -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);
}
}
+385 -1
View File
@@ -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() {
/>
)}
</>
)}
</>
);
}