jacquardThe gate

← 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>
  );
}