← snapshot
4532 bytes
"use client";
import { useCallback, useEffect, useState } from "react";
import { ApiError, api } from "@/lib/api";
import type { PromotePreview, VerdictDto } from "@/lib/types";
import { VerdictPanel } from "./verdict-panel";
/**
* The gate panel: pick refs, watch the dry-run verdict live, and promote
* only when the preview admits. The preview moves nothing.
*/
export function PromotePanel({
repo,
refNames,
defaultFrom,
defaultInto,
}: {
repo: string;
refNames: string[];
defaultFrom?: string;
defaultInto?: string;
}) {
const [from, setFrom] = useState(defaultFrom ?? refNames.find((r) => r !== "main") ?? "");
const [into, setInto] = useState(defaultInto ?? "main");
const [preview, setPreview] = useState<PromotePreview | null>(null);
const [result, setResult] = useState<VerdictDto | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const loadPreview = useCallback(async () => {
if (!from || !into || from === into) {
setPreview(null);
return;
}
setError(null);
setResult(null);
try {
setPreview(await api.promotePreview(repo, from, into));
} catch (e) {
setPreview(null);
setError(e instanceof ApiError ? e.message : String(e));
}
}, [repo, from, into]);
useEffect(() => {
void loadPreview();
}, [loadPreview]);
async function promote() {
setPending(true);
setError(null);
try {
setResult(await api.promote(repo, from, into));
await loadPreview();
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e));
} finally {
setPending(false);
}
}
const admitted = preview?.verdict.verdict === "admitted";
return (
<div>
<div className="jac-cols" style={{ marginTop: 20 }}>
<div className="jac-field" style={{ flex: "1 1 200px", marginTop: 0 }}>
<label className="jac-label" htmlFor="promote-from">
Promote
</label>
<select
id="promote-from"
className="jac-select"
value={from}
onChange={(e) => setFrom(e.target.value)}
>
<option value="">— choose a ref —</option>
{refNames.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
<div className="jac-field" style={{ flex: "1 1 200px", marginTop: 0 }}>
<label className="jac-label" htmlFor="promote-into">
Into
</label>
<select
id="promote-into"
className="jac-select"
value={into}
onChange={(e) => setInto(e.target.value)}
>
{refNames.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
</div>
{error ? <div className="jac-error-box">{error}</div> : null}
{preview ? (
<div style={{ marginTop: 20 }}>
<div className="jac-small" style={{ marginBottom: 10 }}>
Dry run — nothing moves on a preview. Blast radius:{" "}
{preview.changed.length === 0 ? (
"no paths differ"
) : (
<span className="jac-mono">{preview.changed.join(" · ")}</span>
)}
{preview.fast_forward
? ""
: " — and this is not a fast-forward, so promotion will be refused outright."}
</div>
<VerdictPanel verdict={preview.verdict} repo={repo} />
<div className="jac-cta-row">
<button
type="button"
className="jac-btn jac-btn--primary"
disabled={!admitted || !preview.fast_forward || pending}
onClick={promote}
>
{pending ? "Promoting…" : `Promote ${from || "…"} → ${into}`}
</button>
{!admitted ? (
<span className="jac-small">
The button follows the gate: it enables when the preview admits.
</span>
) : null}
</div>
</div>
) : null}
{result ? (
<div style={{ marginTop: 24 }}>
<p className="jac-eyebrow">The promotion itself</p>
<div style={{ marginTop: 10 }}>
<VerdictPanel verdict={result} repo={repo} />
</div>
</div>
) : null}
</div>
);
}