OPC # 0006: OPC Git Trunk-Based management
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -201,14 +201,37 @@ export async function getGitLog(path?: string, limit = 20): Promise<GitCommit[]>
|
||||
|
||||
// ── Promotion / Branch Ladder API ────────────────────────────────────────────
|
||||
|
||||
export interface BuildHistoryRecord {
|
||||
id: string;
|
||||
status: 'Running' | 'Succeeded' | 'Failed';
|
||||
startedAt: string;
|
||||
durationMs: number | null;
|
||||
commitSha: string | null;
|
||||
imageDigest: string | null;
|
||||
}
|
||||
|
||||
export async function getImageBuildHistory(limit = 30): Promise<BuildHistoryRecord[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/image/history?limit=${limit}`);
|
||||
if (!res.ok) throw new Error(`Failed to get build history: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface CommitInfo {
|
||||
sha: string;
|
||||
shortSha: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface BranchStatus {
|
||||
branch: string;
|
||||
exists: boolean;
|
||||
shortHash: string | null;
|
||||
lastCommitSummary: string | null;
|
||||
aheadOfNext: number;
|
||||
behindNext: number;
|
||||
unreleasedLines: string[];
|
||||
branch: string;
|
||||
exists: boolean;
|
||||
shortHash: string | null;
|
||||
lastCommitSummary: string | null;
|
||||
aheadOfNext: number;
|
||||
behindNext: number;
|
||||
unreleasedCommits: CommitInfo[];
|
||||
}
|
||||
|
||||
export interface PromotionRecord {
|
||||
@@ -225,8 +248,8 @@ export interface PromotionRecord {
|
||||
log: string[];
|
||||
}
|
||||
|
||||
export async function getLadderStatus(): Promise<BranchStatus[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/promotions/ladder`);
|
||||
export async function getLadderStatus(repo = 'Clarity'): Promise<BranchStatus[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/promotions/ladder?repo=${encodeURIComponent(repo)}`);
|
||||
if (!res.ok) throw new Error(`Failed to get ladder status: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -246,6 +269,7 @@ export function triggerPromotion(
|
||||
onLine: (line: string) => void,
|
||||
onDone: (record: PromotionRecord) => void,
|
||||
onError: (err: string) => void,
|
||||
repo = 'Clarity',
|
||||
): () => void {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
@@ -255,7 +279,73 @@ export function triggerPromotion(
|
||||
const res = await fetch(`${BASE_URL}/api/promotions/promote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from, to, requestedBy, note }),
|
||||
body: JSON.stringify({ from, to, requestedBy, note, repo }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) { onError(res.statusText); return; }
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (!cancelled) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() ?? '';
|
||||
for (const chunk of parts) {
|
||||
const dataLine = chunk.replace(/^data:\s*/m, '').trim();
|
||||
if (!dataLine) continue;
|
||||
try {
|
||||
const msg = JSON.parse(dataLine);
|
||||
if (msg.done && msg.promotion) onDone(msg.promotion as PromotionRecord);
|
||||
else if (typeof msg.line === 'string') onLine(msg.line);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) onError(e instanceof Error ? e.message : 'Unknown error');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; controller.abort(); };
|
||||
}
|
||||
|
||||
export async function resetBranch(branch: string, toSha: string, repo: string): Promise<void> {
|
||||
const res = await fetch(`${BASE_URL}/api/promotions/reset`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ branch, toSha, repo }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error((body as { error?: string }).error ?? res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
/** Cherry-picks the specified commits (by full SHA) from `from` to `to` and streams SSE progress. */
|
||||
export function triggerCherryPick(
|
||||
shas: string[],
|
||||
from: string,
|
||||
to: string,
|
||||
requestedBy: string,
|
||||
note: string | undefined,
|
||||
onLine: (line: string) => void,
|
||||
onDone: (record: PromotionRecord) => void,
|
||||
onError: (err: string) => void,
|
||||
repo = 'Clarity',
|
||||
): () => void {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/promotions/cherry-pick`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shas, from, to, requestedBy, note, repo }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button, Callout, Intent, Tag, Spinner,
|
||||
Button, Callout, Checkbox, Intent, Tag, Spinner,
|
||||
Dialog, DialogBody, DialogFooter,
|
||||
HTMLTable, Collapse, Card, Elevation, TextArea,
|
||||
HTMLTable, Collapse, TextArea, SegmentedControl,
|
||||
} from '@blueprintjs/core';
|
||||
import {
|
||||
getLadderStatus, getPromotionHistory, triggerPromotion,
|
||||
type BranchStatus, type PromotionRecord,
|
||||
getLadderStatus, getPromotionHistory, triggerPromotion, triggerCherryPick,
|
||||
getImageBuildHistory, resetBranch,
|
||||
type BranchStatus, type CommitInfo, type PromotionRecord, type BuildHistoryRecord,
|
||||
} from '../api/provisioningApi';
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const LADDER: { branch: string; label: string; env: string; intent: Intent }[] = [
|
||||
{ branch: 'develop', label: 'Develop', env: 'fdev', intent: Intent.PRIMARY },
|
||||
{ branch: 'staging', label: 'Staging', env: 'staging', intent: Intent.WARNING },
|
||||
{ branch: 'uat', label: 'UAT', env: 'uat', intent: Intent.DANGER },
|
||||
{ branch: 'master', label: 'Master', env: 'prod', intent: Intent.SUCCESS },
|
||||
const REPOS = ['Clarity', 'OPC', 'Gateway'] as const;
|
||||
type RepoName = typeof REPOS[number];
|
||||
|
||||
const LADDER: { branch: string; label: string; env: string; intent: Intent; color: string }[] = [
|
||||
{ branch: 'develop', label: 'Develop', env: 'dev', intent: Intent.PRIMARY, color: '#215db0' },
|
||||
{ branch: 'staging', label: 'Staging', env: 'staging', intent: Intent.WARNING, color: '#935610' },
|
||||
{ branch: 'uat', label: 'UAT', env: 'uat', intent: Intent.DANGER, color: '#8e292c' },
|
||||
{ branch: 'main', label: 'Main', env: 'prod', intent: Intent.SUCCESS, color: '#1c6e42' },
|
||||
];
|
||||
|
||||
const STATUS_INTENT: Record<string, Intent> = {
|
||||
@@ -25,92 +29,145 @@ const STATUS_INTENT: Record<string, Intent> = {
|
||||
Pending: Intent.NONE,
|
||||
};
|
||||
|
||||
// ── Promotion terminal ────────────────────────────────────────────────────────
|
||||
const BUILD_COLOR: Record<string, string> = {
|
||||
Succeeded: '#1c6e42',
|
||||
Failed: '#c23030',
|
||||
Running: '#2d72d2',
|
||||
};
|
||||
|
||||
// -- BuildSparkline -----------------------------------------------------------
|
||||
|
||||
const MAX_BAR_H = 44;
|
||||
const BAR_W = 6;
|
||||
const BAR_GAP = 2;
|
||||
const N_BARS = 20;
|
||||
|
||||
function BuildSparkline({ builds }: { builds: BuildHistoryRecord[] }) {
|
||||
const recent = builds.slice(0, N_BARS).reverse();
|
||||
const maxMs = Math.max(...recent.map((b) => b.durationMs ?? 0), 1);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: BAR_GAP, height: MAX_BAR_H, marginTop: '0.4rem' }}>
|
||||
{recent.map((b) => {
|
||||
const h = b.durationMs ? Math.max(4, Math.round((b.durationMs / maxMs) * MAX_BAR_H)) : 4;
|
||||
const color = BUILD_COLOR[b.status] ?? '#8f99a8';
|
||||
const date = b.startedAt ? new Date(b.startedAt).toLocaleDateString() : '';
|
||||
const dur = b.durationMs != null ? `${(b.durationMs / 1000).toFixed(1)}s` : '-';
|
||||
const sha = b.commitSha ? b.commitSha.slice(0, 7) : '\u2014';
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
title={`${sha} \u00b7 ${date} \u00b7 ${dur} \u00b7 ${b.status}`}
|
||||
style={{
|
||||
width: BAR_W,
|
||||
height: h,
|
||||
background: color,
|
||||
borderRadius: 2,
|
||||
flexShrink: 0,
|
||||
cursor: 'default',
|
||||
opacity: b.status === 'Running' ? 0.7 : 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- PromotionTerminal --------------------------------------------------------
|
||||
|
||||
function PromotionTerminal({ lines }: { lines: string[] }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1.6,
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 6,
|
||||
height: 300,
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
border: '1px solid #30363d',
|
||||
}}>
|
||||
{lines.length === 0
|
||||
? <span style={{ color: '#484f58' }}>Waiting for promotion output…</span>
|
||||
: lines.map((l, i) => {
|
||||
const color = l.startsWith('✔') ? '#3fb950'
|
||||
: l.startsWith('✖') ? '#f85149'
|
||||
: l.startsWith('⚠') ? '#d29922'
|
||||
: l.startsWith('──') ? '#484f58'
|
||||
: undefined;
|
||||
return <div key={i} style={color ? { color } : undefined}>{l}</div>;
|
||||
})
|
||||
}
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1.6,
|
||||
background: '#0d1117',
|
||||
color: '#c9d1d9',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 6,
|
||||
height: 220,
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
border: '1px solid #30363d',
|
||||
}}
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<span style={{ color: '#484f58' }}>Waiting for output\u2026</span>
|
||||
) : (
|
||||
lines.map((l, i) => {
|
||||
const color =
|
||||
l.startsWith('[ok]') ? '#3fb950' :
|
||||
l.startsWith('[err]') ? '#f85149' :
|
||||
l.startsWith('[warn]') ? '#d29922' :
|
||||
l.startsWith('--') ? '#8f99a8' :
|
||||
undefined;
|
||||
return <div key={i} style={color ? { color } : undefined}>{l}</div>;
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Promote dialog ────────────────────────────────────────────────────────────
|
||||
// -- PromoteDialog ------------------------------------------------------------
|
||||
|
||||
function PromoteDialog({
|
||||
from, to, onClose, onDone,
|
||||
from, to, repo, onClose, onDone,
|
||||
}: {
|
||||
from: string; to: string;
|
||||
from: string; to: string; repo: RepoName;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [note, setNote] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [done, setDone] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [done, setDone] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const cancelRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const fromMeta = LADDER.find((l) => l.branch === from);
|
||||
const toMeta = LADDER.find((l) => l.branch === to);
|
||||
|
||||
const handlePromote = () => {
|
||||
setRunning(true);
|
||||
setLogs([]);
|
||||
setError(null);
|
||||
|
||||
cancelRef.current = triggerPromotion(
|
||||
from, to, 'control-plane', note || undefined,
|
||||
(line) => setLogs((p) => [...p, line]),
|
||||
() => { setRunning(false); setDone(true); onDone(); },
|
||||
(err) => { setError(err); setRunning(false); },
|
||||
(line) => setLogs((p) => [...p, line]),
|
||||
(_record) => { setRunning(false); setDone(true); onDone(); },
|
||||
(err) => { setError(err); setRunning(false); },
|
||||
repo,
|
||||
);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
cancelRef.current?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const fromLabel = LADDER.find((l) => l.branch === from)?.label ?? from;
|
||||
const toLabel = LADDER.find((l) => l.branch === to)?.label ?? to;
|
||||
const handleClose = () => { cancelRef.current?.(); onClose(); };
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={handleClose}
|
||||
title={`Promote ${fromLabel} → ${toLabel}`}
|
||||
style={{ width: 640 }}
|
||||
title={
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Tag intent={fromMeta?.intent} minimal style={{ fontFamily: 'monospace' }}>{from}</Tag>
|
||||
<span style={{ color: '#8f99a8' }}>{'\u2192'}</span>
|
||||
<Tag intent={toMeta?.intent} minimal style={{ fontFamily: 'monospace' }}>{to}</Tag>
|
||||
<span style={{ color: '#8f99a8', fontSize: '0.8rem', marginLeft: '0.25rem' }}>[{repo}]</span>
|
||||
</span>
|
||||
}
|
||||
style={{ width: 580 }}
|
||||
>
|
||||
<DialogBody>
|
||||
{!running && !done && (
|
||||
<>
|
||||
<p style={{ marginBottom: '0.75rem', color: '#8f99a8' }}>
|
||||
This will merge <code>{from}</code> into <code>{to}</code> with a
|
||||
no-fast-forward commit and push to origin.
|
||||
<p style={{ marginBottom: '0.75rem', color: '#738091', fontSize: '0.85rem' }}>
|
||||
Merges <code>{from}</code> into <code>{to}</code> via no-fast-forward and pushes to origin.
|
||||
</p>
|
||||
<TextArea
|
||||
fill
|
||||
@@ -121,24 +178,20 @@ function PromoteDialog({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(running || logs.length > 0) && (
|
||||
<div style={{ marginTop: running && !logs.length ? 0 : '0.75rem' }}>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<PromotionTerminal lines={logs} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Callout intent={Intent.DANGER} style={{ marginTop: '0.5rem' }}>{error}</Callout>
|
||||
)}
|
||||
|
||||
{done && (
|
||||
<Callout intent={Intent.SUCCESS} icon="tick" style={{ marginTop: '0.5rem' }}>
|
||||
Promotion complete. Tenants on <strong>{to}</strong> can now be released.
|
||||
</Callout>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter
|
||||
minimal
|
||||
actions={
|
||||
@@ -150,7 +203,7 @@ function PromoteDialog({
|
||||
<Button
|
||||
intent={Intent.WARNING}
|
||||
icon="arrow-right"
|
||||
text={running ? 'Promoting…' : `Promote ${fromLabel} → ${toLabel}`}
|
||||
text={running ? 'Promoting\u2026' : `Promote ${from} \u2192 ${to}`}
|
||||
loading={running}
|
||||
disabled={running}
|
||||
onClick={handlePromote}
|
||||
@@ -163,91 +216,336 @@ function PromoteDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Branch ladder card ────────────────────────────────────────────────────────
|
||||
// -- ResetDialog -------------------------------------------------------------
|
||||
|
||||
function LadderCard({
|
||||
status, nextBranch, onPromote,
|
||||
function ResetDialog({
|
||||
branch, toSha, prevBranch, repo, onClose, onDone,
|
||||
}: {
|
||||
status: BranchStatus;
|
||||
nextBranch: string | null;
|
||||
onPromote: (from: string, to: string) => void;
|
||||
branch: string;
|
||||
toSha: string;
|
||||
prevBranch: string;
|
||||
repo: string;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const meta = LADDER.find((l) => l.branch === status.branch)!;
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleReset = async () => {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
await resetBranch(branch, toSha, repo);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Reset failed');
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card elevation={Elevation.ONE} style={{ marginBottom: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
|
||||
{/* Branch name + env badge */}
|
||||
<div style={{ flex: '0 0 auto', minWidth: 120 }}>
|
||||
<Tag intent={meta.intent} large minimal style={{ fontFamily: 'monospace', fontWeight: 600 }}>
|
||||
{status.branch}
|
||||
</Tag>
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: '#8f99a8' }}>
|
||||
→ {meta.env}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Last commit */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{status.exists ? (
|
||||
<span style={{ fontSize: '0.8rem', color: '#8f99a8', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
|
||||
<code style={{ color: '#4a90d9', marginRight: '0.4rem' }}>{status.shortHash}</code>
|
||||
{status.lastCommitSummary}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.8rem', color: '#484f58' }}>Branch does not exist yet</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ahead badge + unreleased toggle */}
|
||||
{status.exists && status.aheadOfNext > 0 && nextBranch && (
|
||||
<Button
|
||||
minimal small
|
||||
intent={Intent.WARNING}
|
||||
icon={open ? 'chevron-up' : 'layers'}
|
||||
text={`${status.aheadOfNext} unreleased`}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status.exists && status.aheadOfNext === 0 && nextBranch && (
|
||||
<Tag minimal intent={Intent.SUCCESS} icon="tick">In sync</Tag>
|
||||
)}
|
||||
|
||||
{/* Promote button */}
|
||||
{status.exists && nextBranch && (
|
||||
<Button
|
||||
intent={Intent.PRIMARY}
|
||||
small
|
||||
icon="arrow-right"
|
||||
text={`Promote → ${nextBranch}`}
|
||||
disabled={status.aheadOfNext === 0}
|
||||
onClick={() => onPromote(status.branch, nextBranch)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unreleased commit list */}
|
||||
<Collapse isOpen={open && status.unreleasedLines.length > 0}>
|
||||
<div style={{ marginTop: '0.75rem', paddingLeft: '0.5rem', borderLeft: '2px solid #30363d' }}>
|
||||
{status.unreleasedLines.map((line, i) => {
|
||||
const [hash, ...rest] = line.split(' ');
|
||||
return (
|
||||
<div key={i} style={{ fontSize: '0.78rem', marginBottom: '0.2rem' }}>
|
||||
<code style={{ color: '#4a90d9', marginRight: '0.5rem' }}>{hash}</code>
|
||||
<span style={{ color: '#c9d1d9' }}>{rest.join(' ')}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Collapse>
|
||||
</Card>
|
||||
<Dialog isOpen onClose={onClose} title="Reset branch" style={{ width: 480 }}>
|
||||
<DialogBody>
|
||||
<Callout intent={Intent.WARNING} icon="warning-sign" style={{ marginBottom: '1rem' }}>
|
||||
<strong>{branch}</strong> has commits that are not in <strong>{prevBranch}</strong>.
|
||||
This is a diverged state — likely caused by a merge commit landing directly on {branch}.
|
||||
</Callout>
|
||||
<p style={{ fontSize: '0.85rem', color: '#738091', marginBottom: '0.5rem' }}>
|
||||
This will force-reset <code>{branch}</code> to{' '}
|
||||
<code style={{ color: '#2d72d2' }}>{toSha}</code> (current {prevBranch} tip)
|
||||
and force-push to origin.
|
||||
</p>
|
||||
<p style={{ fontSize: '0.85rem', color: '#c23030' }}>
|
||||
Any commits currently only on {branch} will be unreachable. Make sure they are
|
||||
backported to <code>{prevBranch}</code> first if they need to be kept.
|
||||
</p>
|
||||
{error && <Callout intent={Intent.DANGER} style={{ marginTop: '0.75rem' }}>{error}</Callout>}
|
||||
</DialogBody>
|
||||
<DialogFooter
|
||||
minimal
|
||||
actions={
|
||||
<>
|
||||
<Button text="Cancel" onClick={onClose} disabled={running} />
|
||||
<Button
|
||||
intent={Intent.DANGER}
|
||||
icon="reset"
|
||||
text={running ? 'Resetting\u2026' : `Force-reset ${branch} \u2192 ${toSha}`}
|
||||
loading={running}
|
||||
disabled={running}
|
||||
onClick={handleReset}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── History table ─────────────────────────────────────────────────────────────
|
||||
// -- CherryPickDialog ---------------------------------------------------------
|
||||
|
||||
function CherryPickDialog({
|
||||
shas, from, to, repo, onClose, onDone,
|
||||
}: {
|
||||
shas: string[];
|
||||
from: string;
|
||||
to: string;
|
||||
repo: RepoName;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [running, setRunning] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [done, setDone] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const cancelRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const fromMeta = LADDER.find((l) => l.branch === from);
|
||||
const toMeta = LADDER.find((l) => l.branch === to);
|
||||
|
||||
const handleRun = () => {
|
||||
setRunning(true);
|
||||
setLogs([]);
|
||||
setError(null);
|
||||
cancelRef.current = triggerCherryPick(
|
||||
shas, from, to, 'control-plane', undefined,
|
||||
(line) => setLogs((p) => [...p, line]),
|
||||
(_record) => { setRunning(false); setDone(true); onDone(); },
|
||||
(err) => { setError(err); setRunning(false); },
|
||||
repo,
|
||||
);
|
||||
};
|
||||
|
||||
const handleClose = () => { cancelRef.current?.(); onClose(); };
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ color: '#8f99a8', fontSize: '0.8rem' }}>Cherry-pick</span>
|
||||
<Tag intent={fromMeta?.intent} minimal style={{ fontFamily: 'monospace' }}>{from}</Tag>
|
||||
<span style={{ color: '#8f99a8' }}>{'\u2192'}</span>
|
||||
<Tag intent={toMeta?.intent} minimal style={{ fontFamily: 'monospace' }}>{to}</Tag>
|
||||
<span style={{ color: '#8f99a8', fontSize: '0.8rem', marginLeft: '0.25rem' }}>[{repo}]</span>
|
||||
</span>
|
||||
}
|
||||
style={{ width: 580 }}
|
||||
>
|
||||
<DialogBody>
|
||||
{!running && !done && (
|
||||
<p style={{ marginBottom: '0.75rem', color: '#738091', fontSize: '0.85rem' }}>
|
||||
Applies <strong>{shas.length}</strong> selected commit{shas.length > 1 ? 's' : ''} as new
|
||||
commits on <code>{to}</code>. The branches will diverge — use the Reset button to
|
||||
re-align if a full fast-forward promote is needed later.
|
||||
</p>
|
||||
)}
|
||||
{(running || logs.length > 0) && (
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<PromotionTerminal lines={logs} />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<Callout intent={Intent.DANGER} style={{ marginTop: '0.5rem' }}>{error}</Callout>
|
||||
)}
|
||||
{done && (
|
||||
<Callout intent={Intent.SUCCESS} icon="tick" style={{ marginTop: '0.5rem' }}>
|
||||
Cherry-pick complete. <strong>{shas.length}</strong> commit{shas.length > 1 ? 's' : ''} applied to <strong>{to}</strong>.
|
||||
</Callout>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter
|
||||
minimal
|
||||
actions={
|
||||
done ? (
|
||||
<Button intent={Intent.SUCCESS} text="Close" onClick={handleClose} />
|
||||
) : (
|
||||
<>
|
||||
<Button text="Cancel" onClick={handleClose} disabled={running} />
|
||||
<Button
|
||||
intent={Intent.PRIMARY}
|
||||
icon="git-merge"
|
||||
text={running ? 'Cherry-picking\u2026' : `Cherry-pick ${shas.length} commit${shas.length > 1 ? 's' : ''} \u2192 ${to}`}
|
||||
loading={running}
|
||||
disabled={running}
|
||||
onClick={handleRun}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// -- LadderStep ---------------------------------------------------------------
|
||||
|
||||
function LadderStep({
|
||||
status, prevStatus, meta, nextBranch, isLast, onPromote, onReset, onCherryPick, builds,
|
||||
}: {
|
||||
status: BranchStatus | undefined;
|
||||
prevStatus: BranchStatus | undefined;
|
||||
meta: typeof LADDER[number];
|
||||
nextBranch: string | null;
|
||||
isLast: boolean;
|
||||
onPromote: (from: string, to: string) => void;
|
||||
onReset: (branch: string, toSha: string) => void;
|
||||
onCherryPick: (shas: string[], from: string, to: string) => void;
|
||||
builds?: BuildHistoryRecord[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedShas, setSelectedShas] = useState<Set<string>>(new Set());
|
||||
const toggleSha = (sha: string) =>
|
||||
setSelectedShas(prev => { const next = new Set(prev); if (next.has(sha)) next.delete(sha); else next.add(sha); return next; });
|
||||
const exists = status?.exists ?? false;
|
||||
|
||||
// Diverged = this branch has commits not in the branch before it.
|
||||
// prevStatus.behindNext > 0 means prevBranch is behind THIS branch, i.e. we have extra commits.
|
||||
const isDiverged = (prevStatus?.behindNext ?? 0) > 0;
|
||||
const prevTip = prevStatus?.shortHash ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="job-card"
|
||||
style={{ borderLeft: `4px solid ${meta.color}`, opacity: exists ? 1 : 0.55 }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem' }}>
|
||||
|
||||
{/* Branch label */}
|
||||
<div style={{ width: 100, flexShrink: 0 }}>
|
||||
<Tag
|
||||
intent={meta.intent}
|
||||
minimal
|
||||
large
|
||||
style={{ fontFamily: 'monospace', fontWeight: 700, marginBottom: '0.2rem' }}
|
||||
>
|
||||
{meta.branch}
|
||||
</Tag>
|
||||
<div style={{ fontSize: '0.68rem', color: '#8f99a8', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{meta.env}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit info + sparkline */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{exists && status ? (
|
||||
<>
|
||||
<div style={{ fontSize: '0.8rem', lineHeight: 1.5, overflow: 'hidden' }}>
|
||||
<code style={{ color: '#2d72d2', marginRight: '0.4rem' }}>{status.shortHash}</code>
|
||||
<span style={{ color: '#738091' }}>{status.lastCommitSummary}</span>
|
||||
</div>
|
||||
{builds && builds.length > 0 && <BuildSparkline builds={builds} />}
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.8rem', color: '#8f99a8', fontStyle: 'italic' }}>No branch yet</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diverged / reset warning */}
|
||||
{exists && isDiverged && prevTip && (
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
intent={Intent.DANGER}
|
||||
small
|
||||
icon="reset"
|
||||
text="Diverged — Reset"
|
||||
onClick={() => onReset(meta.branch, prevTip)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync badge + promote button */}
|
||||
{exists && nextBranch && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexShrink: 0 }}>
|
||||
{(status?.aheadOfNext ?? 0) > 0 ? (
|
||||
<Button
|
||||
minimal
|
||||
small
|
||||
intent={Intent.WARNING}
|
||||
icon={open ? 'chevron-up' : 'layers'}
|
||||
text={`${status!.aheadOfNext} unreleased`}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
/>
|
||||
) : (
|
||||
<Tag minimal intent={Intent.SUCCESS} icon="tick" style={{ fontSize: '0.72rem' }}>
|
||||
In sync
|
||||
</Tag>
|
||||
)}
|
||||
<Button
|
||||
intent={Intent.PRIMARY}
|
||||
small
|
||||
icon="arrow-right"
|
||||
text={`Promote \u2192 ${nextBranch}`}
|
||||
disabled={(status?.aheadOfNext ?? 0) === 0}
|
||||
onClick={() => onPromote(meta.branch, nextBranch)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live badge for main */}
|
||||
{isLast && exists && (
|
||||
<Tag minimal intent={Intent.SUCCESS} icon="cloud-upload" style={{ fontSize: '0.72rem', flexShrink: 0 }}>
|
||||
Live
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unreleased commit list with cherry-pick selection */}
|
||||
<Collapse isOpen={open && (status?.unreleasedCommits.length ?? 0) > 0}>
|
||||
<div style={{
|
||||
marginTop: '0.75rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: '#f6f7f9',
|
||||
border: '1px solid #e5e8eb',
|
||||
borderRadius: 4,
|
||||
fontSize: '0.74rem',
|
||||
}}>
|
||||
{status?.unreleasedCommits.map((c: CommitInfo) => (
|
||||
<div
|
||||
key={c.sha}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '0.1rem',
|
||||
padding: '0.2rem 0.35rem',
|
||||
borderRadius: 3,
|
||||
cursor: 'pointer',
|
||||
background: selectedShas.has(c.sha) ? '#ebf3fd' : 'transparent',
|
||||
}}
|
||||
onClick={() => toggleSha(c.sha)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedShas.has(c.sha)}
|
||||
onChange={() => toggleSha(c.sha)}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
<code style={{ color: '#2d72d2', flexShrink: 0 }}>{c.shortSha}</code>
|
||||
<span style={{ color: '#1c2127', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{c.message}
|
||||
</span>
|
||||
<span style={{ color: '#8f99a8', flexShrink: 0, fontSize: '0.68rem' }}>{c.author}</span>
|
||||
</div>
|
||||
))}
|
||||
{nextBranch && selectedShas.size > 0 && (
|
||||
<div style={{ marginTop: '0.5rem', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', borderTop: '1px solid #e5e8eb', paddingTop: '0.5rem' }}>
|
||||
<Button minimal small text="Clear" onClick={() => setSelectedShas(new Set())} />
|
||||
<Button
|
||||
intent={Intent.PRIMARY}
|
||||
small
|
||||
icon="git-merge"
|
||||
text={`Cherry-pick ${selectedShas.size} \u2192 ${nextBranch}`}
|
||||
onClick={() => onCherryPick([...selectedShas], meta.branch, nextBranch)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- PromotionHistoryTable ----------------------------------------------------
|
||||
|
||||
function PromotionHistoryTable({ records }: { records: PromotionRecord[] }) {
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
@@ -256,7 +554,7 @@ function PromotionHistoryTable({ records }: { records: PromotionRecord[] }) {
|
||||
return <p style={{ color: '#8f99a8', fontSize: '0.85rem' }}>No promotions yet.</p>;
|
||||
|
||||
return (
|
||||
<HTMLTable style={{ width: '100%', fontSize: '0.8rem' }}>
|
||||
<HTMLTable style={{ width: '100%', fontSize: '0.8rem' }} striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Promotion</th>
|
||||
@@ -269,49 +567,65 @@ function PromotionHistoryTable({ records }: { records: PromotionRecord[] }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => (
|
||||
<>
|
||||
<tr key={r.id}>
|
||||
<td style={{ fontFamily: 'monospace' }}>{r.fromBranch} → {r.toBranch}</td>
|
||||
<React.Fragment key={r.id}>
|
||||
<tr>
|
||||
<td style={{ fontFamily: 'monospace' }}>{r.fromBranch} {'\u2192'} {r.toBranch}</td>
|
||||
<td><Tag intent={STATUS_INTENT[r.status] ?? Intent.NONE} minimal round>{r.status}</Tag></td>
|
||||
<td style={{ color: '#8f99a8' }}>{r.commitCount}</td>
|
||||
<td style={{ color: '#8f99a8' }}>{r.requestedBy}</td>
|
||||
<td style={{ color: '#8f99a8' }}>{r.createdAt ? new Date(r.createdAt).toLocaleString() : '—'}</td>
|
||||
<td style={{ color: '#8f99a8' }}>{r.createdAt ? new Date(r.createdAt).toLocaleString() : '\u2014'}</td>
|
||||
<td>
|
||||
<Button minimal small icon={expanded === r.id ? 'chevron-up' : 'chevron-down'}
|
||||
onClick={() => setExpanded(expanded === r.id ? null : r.id)} />
|
||||
<Button
|
||||
minimal small
|
||||
icon={expanded === r.id ? 'chevron-up' : 'chevron-down'}
|
||||
onClick={() => setExpanded(expanded === r.id ? null : r.id)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded === r.id && (
|
||||
<tr key={`${r.id}-log`}>
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: '0.5rem 1rem' }}>
|
||||
{r.note && <p style={{ color: '#8f99a8', fontSize: '0.78rem', margin: '0 0 0.5rem' }}>Note: {r.note}</p>}
|
||||
{r.note && (
|
||||
<p style={{ color: '#8f99a8', fontSize: '0.78rem', margin: '0 0 0.5rem' }}>
|
||||
Note: {r.note}
|
||||
</p>
|
||||
)}
|
||||
<PromotionTerminal lines={r.log} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
// -- Page ---------------------------------------------------------------------
|
||||
|
||||
export default function BranchPage() {
|
||||
const [ladder, setLadder] = useState<BranchStatus[]>([]);
|
||||
const [history, setHistory] = useState<PromotionRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialog, setDialog] = useState<{ from: string; to: string } | null>(null);
|
||||
const [repo, setRepo] = useState<RepoName>('Clarity');
|
||||
const [ladder, setLadder] = useState<BranchStatus[]>([]);
|
||||
const [history, setHistory] = useState<PromotionRecord[]>([]);
|
||||
const [builds, setBuilds] = useState<BuildHistoryRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialog, setDialog] = useState<{ from: string; to: string } | null>(null);
|
||||
const [resetDialog, setResetDialog] = useState<{ branch: string; toSha: string } | null>(null);
|
||||
const [cherryPickDialog, setCherryPickDialog] = useState<{ shas: string[]; from: string; to: string } | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
const load = async (r: RepoName = repo) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [l, h] = await Promise.all([getLadderStatus(), getPromotionHistory()]);
|
||||
const [l, h, b] = await Promise.all([
|
||||
getLadderStatus(r),
|
||||
getPromotionHistory(),
|
||||
getImageBuildHistory(),
|
||||
]);
|
||||
setLadder(l);
|
||||
setHistory(h);
|
||||
setBuilds(b);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load');
|
||||
} finally {
|
||||
@@ -319,16 +633,34 @@ export default function BranchPage() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { (async () => { await load(); })(); }, []);
|
||||
useEffect(() => { load(); }, [repo]);
|
||||
|
||||
const handleRepoChange = (val: string) => {
|
||||
setRepo(val as RepoName);
|
||||
setLadder([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Branch Ladder</h1>
|
||||
<p>Promote code through <code>develop → staging → uat → main</code>. Developers merge to <code>develop</code>, Control Plane handles everything above.</p>
|
||||
<p>
|
||||
Promote code through{' '}
|
||||
<code>develop {'\u2192'} staging {'\u2192'} uat {'\u2192'} main</code>.{' '}
|
||||
Developers push to <code>develop</code> \u2014 Control Plane handles everything above.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<SegmentedControl
|
||||
options={REPOS.map((r) => ({ label: r, value: r }))}
|
||||
value={repo}
|
||||
onValueChange={handleRepoChange}
|
||||
small
|
||||
/>
|
||||
<Button icon="refresh" minimal onClick={() => load()} loading={loading} title="Refresh" />
|
||||
</div>
|
||||
<Button icon="refresh" minimal onClick={load} loading={loading} title="Refresh" />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -337,45 +669,107 @@ export default function BranchPage() {
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{loading && !ladder.length ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#8f99a8' }}>
|
||||
<Spinner size={16} /> Loading branch status…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ── Ladder ── */}
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
{ladder.map((s, i) => (
|
||||
<LadderCard
|
||||
key={s.branch}
|
||||
status={s}
|
||||
nextBranch={i + 1 < LADDER.length ? LADDER[i + 1].branch : null}
|
||||
onPromote={(from, to) => setDialog({ from, to })}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
{/* Vertical ladder */}
|
||||
<section style={{ marginBottom: '2rem', maxWidth: 860 }}>
|
||||
{loading && !ladder.length ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#8f99a8', padding: '1.5rem 0' }}>
|
||||
<Spinner size={16} /> Loading branch status\u2026
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{LADDER.map((meta, i) => {
|
||||
const status = ladder.find((s) => s.branch === meta.branch);
|
||||
const prevStatus = i > 0 ? ladder.find((s) => s.branch === LADDER[i - 1].branch) : undefined;
|
||||
const nextBranch = i + 1 < LADDER.length ? LADDER[i + 1].branch : null;
|
||||
const isLast = i === LADDER.length - 1;
|
||||
return (
|
||||
<div key={meta.branch}>
|
||||
<LadderStep
|
||||
status={status}
|
||||
prevStatus={prevStatus}
|
||||
meta={meta}
|
||||
nextBranch={nextBranch}
|
||||
isLast={isLast}
|
||||
onPromote={(from, to) => setDialog({ from, to })}
|
||||
onReset={(branch, toSha) => setResetDialog({ branch, toSha })}
|
||||
onCherryPick={(shas, from, to) => setCherryPickDialog({ shas, from, to })}
|
||||
builds={meta.branch === 'develop' ? builds : undefined}
|
||||
/>
|
||||
{!isLast && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '1.5rem',
|
||||
height: 20,
|
||||
color: '#c5cbd3',
|
||||
fontSize: '0.9rem',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
{'\u2193'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── History ── */}
|
||||
<section>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '0.9rem', color: '#8f99a8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Promotion History
|
||||
</h3>
|
||||
</div>
|
||||
<PromotionHistoryTable records={history} />
|
||||
</section>
|
||||
</>
|
||||
{/* Promotion history */}
|
||||
{!loading && (
|
||||
<section>
|
||||
<h3 style={{
|
||||
margin: '0 0 0.75rem',
|
||||
fontSize: '0.85rem',
|
||||
color: '#738091',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
}}>
|
||||
Promotion History
|
||||
</h3>
|
||||
<PromotionHistoryTable records={history} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Promotion dialog ── */}
|
||||
{/* Promote dialog */}
|
||||
{dialog && (
|
||||
<PromoteDialog
|
||||
from={dialog.from}
|
||||
to={dialog.to}
|
||||
repo={repo}
|
||||
onClose={() => setDialog(null)}
|
||||
onDone={() => { setDialog(null); load(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reset dialog */}
|
||||
{resetDialog && (() => {
|
||||
const branchIdx = LADDER.findIndex((l) => l.branch === resetDialog.branch);
|
||||
const prevBranch = branchIdx > 0 ? LADDER[branchIdx - 1].branch : 'develop';
|
||||
return (
|
||||
<ResetDialog
|
||||
branch={resetDialog.branch}
|
||||
toSha={resetDialog.toSha}
|
||||
prevBranch={prevBranch}
|
||||
repo={repo}
|
||||
onClose={() => setResetDialog(null)}
|
||||
onDone={() => { setResetDialog(null); load(); }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Cherry-pick dialog */}
|
||||
{cherryPickDialog && (
|
||||
<CherryPickDialog
|
||||
shas={cherryPickDialog.shas}
|
||||
from={cherryPickDialog.from}
|
||||
to={cherryPickDialog.to}
|
||||
repo={repo}
|
||||
onClose={() => setCherryPickDialog(null)}
|
||||
onDone={() => { setCherryPickDialog(null); load(); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user