|
|
|
@@ -6,9 +6,9 @@ import {
|
|
|
|
|
} from '@blueprintjs/core';
|
|
|
|
|
import {
|
|
|
|
|
getLadderStatus, getPromotionHistory, triggerPromotion, triggerCherryPick,
|
|
|
|
|
resetBranch, getAllConformanceReports, createLadderBranch,
|
|
|
|
|
resetBranch, getAllConformanceReports, createLadderBranch, getBuildGate,
|
|
|
|
|
type BranchStatus, type CommitInfo, type PromotionRecord,
|
|
|
|
|
type ConformanceReport, type BranchConformanceCheck,
|
|
|
|
|
type ConformanceReport, type BranchConformanceCheck, type BuildGate,
|
|
|
|
|
} from '../api/promotionApi';
|
|
|
|
|
import { getImageBuildHistory, type BuildHistoryRecord } from '../api/imageApi';
|
|
|
|
|
|
|
|
|
@@ -37,6 +37,19 @@ const BUILD_COLOR: Record<string, string> = {
|
|
|
|
|
Running: '#2d72d2',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// -- OPC tag helper -----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const OPC_TAG_RE = /OPC\s*#\s*(\d+)/gi;
|
|
|
|
|
function extractOpcTags(message: string): string[] {
|
|
|
|
|
const tags: string[] = [];
|
|
|
|
|
let m: RegExpExecArray | null;
|
|
|
|
|
// Reset lastIndex before every use (global flag retains state)
|
|
|
|
|
OPC_TAG_RE.lastIndex = 0;
|
|
|
|
|
while ((m = OPC_TAG_RE.exec(message)) !== null)
|
|
|
|
|
tags.push(`OPC # ${m[1].padStart(4, '0')}`);
|
|
|
|
|
return [...new Set(tags)];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- BuildSparkline -----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const MAX_BAR_H = 44;
|
|
|
|
@@ -120,19 +133,33 @@ function PromotionTerminal({ lines }: { lines: string[] }) {
|
|
|
|
|
// -- PromoteDialog ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function PromoteDialog({
|
|
|
|
|
from, to, repo, onClose, onDone,
|
|
|
|
|
from, to, repo, fromSha, onClose, onDone,
|
|
|
|
|
}: {
|
|
|
|
|
from: string; to: string; repo: RepoName;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onDone: () => void;
|
|
|
|
|
from: string;
|
|
|
|
|
to: string;
|
|
|
|
|
repo: RepoName;
|
|
|
|
|
fromSha?: string | null;
|
|
|
|
|
onClose: () => 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 [gate, setGate] = useState<BuildGate | null>(null);
|
|
|
|
|
const [gateLoading, setGateLoading] = useState(false);
|
|
|
|
|
const cancelRef = useRef<(() => void) | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!fromSha) return;
|
|
|
|
|
setGateLoading(true);
|
|
|
|
|
getBuildGate(fromSha)
|
|
|
|
|
.then(setGate)
|
|
|
|
|
.catch(() => {})
|
|
|
|
|
.finally(() => setGateLoading(false));
|
|
|
|
|
}, [fromSha]);
|
|
|
|
|
|
|
|
|
|
const fromMeta = LADDER.find((l) => l.branch === from);
|
|
|
|
|
const toMeta = LADDER.find((l) => l.branch === to);
|
|
|
|
|
|
|
|
|
@@ -171,6 +198,30 @@ function PromoteDialog({
|
|
|
|
|
<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>
|
|
|
|
|
{fromSha && (
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.6rem' }}>
|
|
|
|
|
<span style={{ fontSize: '0.82rem', color: '#738091' }}>Build gate:</span>
|
|
|
|
|
{gateLoading ? (
|
|
|
|
|
<Spinner size={12} />
|
|
|
|
|
) : gate ? (
|
|
|
|
|
<Tag
|
|
|
|
|
intent={gate.status === 'Green' ? Intent.SUCCESS : gate.status === 'Red' ? Intent.DANGER : Intent.WARNING}
|
|
|
|
|
minimal
|
|
|
|
|
icon={gate.status === 'Green' ? 'tick-circle' : gate.status === 'Red' ? 'error' : 'warning-sign' as any}
|
|
|
|
|
>
|
|
|
|
|
{gate.status === 'Green' ? 'Passed' : gate.status === 'Red' ? 'Build failed' : gate.status}
|
|
|
|
|
</Tag>
|
|
|
|
|
) : null}
|
|
|
|
|
{gate?.buildId && (
|
|
|
|
|
<code style={{ fontSize: '0.72rem', color: '#8f99a8' }}>{gate.buildId}</code>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{gate?.status === 'Red' && (
|
|
|
|
|
<Callout intent={Intent.DANGER} icon="error" style={{ marginBottom: '0.75rem', fontSize: '0.82rem' }}>
|
|
|
|
|
The build for this commit failed. Fix it before promoting to prevent broken code reaching <strong>{to}</strong>.
|
|
|
|
|
</Callout>
|
|
|
|
|
)}
|
|
|
|
|
<TextArea
|
|
|
|
|
fill
|
|
|
|
|
rows={3}
|
|
|
|
@@ -207,7 +258,7 @@ function PromoteDialog({
|
|
|
|
|
icon="arrow-right"
|
|
|
|
|
text={running ? 'Promoting\u2026' : `Promote ${from} \u2192 ${to}`}
|
|
|
|
|
loading={running}
|
|
|
|
|
disabled={running}
|
|
|
|
|
disabled={running || gate?.status === 'Red'}
|
|
|
|
|
onClick={handlePromote}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
@@ -286,9 +337,10 @@ function ResetDialog({
|
|
|
|
|
// -- CherryPickDialog ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function CherryPickDialog({
|
|
|
|
|
shas, from, to, repo, onClose, onDone,
|
|
|
|
|
shas, commits, from, to, repo, onClose, onDone,
|
|
|
|
|
}: {
|
|
|
|
|
shas: string[];
|
|
|
|
|
commits: CommitInfo[];
|
|
|
|
|
from: string;
|
|
|
|
|
to: string;
|
|
|
|
|
repo: RepoName;
|
|
|
|
@@ -336,11 +388,28 @@ function CherryPickDialog({
|
|
|
|
|
>
|
|
|
|
|
<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>
|
|
|
|
|
<>
|
|
|
|
|
<p style={{ marginBottom: '0.5rem', 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>
|
|
|
|
|
{commits.length > 0 && (
|
|
|
|
|
<div style={{ background: '#f6f7f9', border: '1px solid #e5e8eb', borderRadius: 4, padding: '0.4rem 0.6rem', fontSize: '0.74rem', marginBottom: '0.25rem' }}>
|
|
|
|
|
{commits.map(c => (
|
|
|
|
|
<div key={c.sha} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.1rem' }}>
|
|
|
|
|
<code style={{ color: '#2d72d2', flexShrink: 0 }}>{c.shortSha}</code>
|
|
|
|
|
{extractOpcTags(c.message).map(tag => (
|
|
|
|
|
<Tag key={tag} intent={Intent.PRIMARY} minimal round style={{ fontSize: '0.65rem', flexShrink: 0, padding: '0 4px' }}>
|
|
|
|
|
{tag}
|
|
|
|
|
</Tag>
|
|
|
|
|
))}
|
|
|
|
|
<span style={{ color: '#1c2127', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{c.message}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{(running || logs.length > 0) && (
|
|
|
|
|
<div style={{ marginTop: '0.75rem' }}>
|
|
|
|
@@ -390,9 +459,9 @@ function LadderStep({
|
|
|
|
|
meta: typeof LADDER[number];
|
|
|
|
|
nextBranch: string | null;
|
|
|
|
|
isLast: boolean;
|
|
|
|
|
onPromote: (from: string, to: string) => void;
|
|
|
|
|
onPromote: (from: string, to: string, tipSha: string | null) => void;
|
|
|
|
|
onReset: (branch: string, toSha: string) => void;
|
|
|
|
|
onCherryPick: (shas: string[], from: string, to: string) => void;
|
|
|
|
|
onCherryPick: (shas: string[], commits: CommitInfo[], from: string, to: string) => void;
|
|
|
|
|
builds?: BuildHistoryRecord[];
|
|
|
|
|
}) {
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
@@ -479,7 +548,7 @@ function LadderStep({
|
|
|
|
|
icon="arrow-right"
|
|
|
|
|
text={`Promote \u2192 ${nextBranch}`}
|
|
|
|
|
disabled={(status?.aheadOfNext ?? 0) === 0}
|
|
|
|
|
onClick={() => onPromote(meta.branch, nextBranch)}
|
|
|
|
|
onClick={() => onPromote(meta.branch, nextBranch, status?.tipSha ?? null)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
@@ -523,6 +592,11 @@ function LadderStep({
|
|
|
|
|
style={{ margin: 0 }}
|
|
|
|
|
/>
|
|
|
|
|
<code style={{ color: '#2d72d2', flexShrink: 0 }}>{c.shortSha}</code>
|
|
|
|
|
{extractOpcTags(c.message).map(tag => (
|
|
|
|
|
<Tag key={tag} intent={Intent.PRIMARY} minimal round style={{ fontSize: '0.65rem', flexShrink: 0, padding: '0 4px' }}>
|
|
|
|
|
{tag}
|
|
|
|
|
</Tag>
|
|
|
|
|
))}
|
|
|
|
|
<span style={{ color: '#1c2127', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
|
|
|
{c.message}
|
|
|
|
|
</span>
|
|
|
|
@@ -537,7 +611,7 @@ function LadderStep({
|
|
|
|
|
small
|
|
|
|
|
icon="git-merge"
|
|
|
|
|
text={`Cherry-pick ${selectedShas.size} \u2192 ${nextBranch}`}
|
|
|
|
|
onClick={() => onCherryPick([...selectedShas], meta.branch, nextBranch)}
|
|
|
|
|
onClick={() => onCherryPick([...selectedShas], (status?.unreleasedCommits ?? []).filter(c => selectedShas.has(c.sha)), meta.branch, nextBranch)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
@@ -967,9 +1041,9 @@ export default function BranchPage() {
|
|
|
|
|
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 [dialog, setDialog] = useState<{ from: string; to: string; tipSha: string | null } | 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 [cherryPickDialog, setCherryPickDialog] = useState<{ shas: string[]; commits: CommitInfo[]; from: string; to: string } | null>(null);
|
|
|
|
|
|
|
|
|
|
const load = async (r: RepoName = repo) => {
|
|
|
|
|
setLoading(true);
|
|
|
|
@@ -1073,9 +1147,9 @@ export default function BranchPage() {
|
|
|
|
|
meta={meta}
|
|
|
|
|
nextBranch={nextBranch}
|
|
|
|
|
isLast={isLast}
|
|
|
|
|
onPromote={(from, to) => setDialog({ from, to })}
|
|
|
|
|
onPromote={(from, to, tipSha) => setDialog({ from, to, tipSha })}
|
|
|
|
|
onReset={(branch, toSha) => setResetDialog({ branch, toSha })}
|
|
|
|
|
onCherryPick={(shas, from, to) => setCherryPickDialog({ shas, from, to })}
|
|
|
|
|
onCherryPick={(shas, commits, from, to) => setCherryPickDialog({ shas, commits, from, to })}
|
|
|
|
|
builds={meta.branch === 'develop' ? builds : undefined}
|
|
|
|
|
/>
|
|
|
|
|
{!isLast && (
|
|
|
|
@@ -1121,6 +1195,7 @@ export default function BranchPage() {
|
|
|
|
|
from={dialog.from}
|
|
|
|
|
to={dialog.to}
|
|
|
|
|
repo={repo}
|
|
|
|
|
fromSha={dialog.tipSha}
|
|
|
|
|
onClose={() => setDialog(null)}
|
|
|
|
|
onDone={() => { setDialog(null); load(); }}
|
|
|
|
|
/>
|
|
|
|
@@ -1146,6 +1221,7 @@ export default function BranchPage() {
|
|
|
|
|
{cherryPickDialog && (
|
|
|
|
|
<CherryPickDialog
|
|
|
|
|
shas={cherryPickDialog.shas}
|
|
|
|
|
commits={cherryPickDialog.commits}
|
|
|
|
|
from={cherryPickDialog.from}
|
|
|
|
|
to={cherryPickDialog.to}
|
|
|
|
|
repo={repo}
|
|
|
|
|