diff --git a/apps/loopover-ui/src/components/site/app-panels/activation-preview.test.tsx b/apps/loopover-ui/src/components/site/app-panels/activation-preview.test.tsx index 9adc0939e7..7bbabfbe4b 100644 --- a/apps/loopover-ui/src/components/site/app-panels/activation-preview.test.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/activation-preview.test.tsx @@ -100,58 +100,14 @@ describe("ActivationPreview", () => { await waitFor(() => expect(screen.getByText(/No recent pull requests yet/i)).toBeTruthy()); }); - it("shows the enable-advisory action, posts activation, and reflects the enabled state after the round-trip", async () => { - apiFetch.mockResolvedValueOnce({ ok: true, data: BASE_PREVIEW }); + it("shows informational (non-actionable) status instead of an activation button when not yet enabled (#6444)", async () => { + apiFetch.mockResolvedValue({ ok: true, data: BASE_PREVIEW }); render(); await waitFor(() => expect(screen.getByText(BASE_PREVIEW.summary)).toBeTruthy()); - const activateButton = screen.getByRole("button", { name: /enable advisory mode/i }); - - apiFetch.mockResolvedValueOnce({ - ok: true, - data: { - repoFullName: "acme/widgets", - reviewCheckMode: "required", - checkRunMode: "enabled", - linkedIssueGateMode: "advisory", - duplicatePrGateMode: "advisory", - qualityGateMode: "advisory", - }, - }); - // Reload after activation reports the gate is now on — the button should disappear. - apiFetch.mockResolvedValueOnce({ - ok: true, - data: { ...BASE_PREVIEW, currentReviewCheckMode: "required", recommendedAction: null }, - }); - - fireEvent.click(activateButton); - - await waitFor(() => - expect( - screen.getByText(/Advisory mode enabled\. LoopOver will now surface guidance/i), - ).toBeTruthy(), - ); - await waitFor(() => expect(screen.getByText(/Advisory mode is already enabled/i)).toBeTruthy()); expect(screen.queryByRole("button", { name: /enable advisory mode/i })).toBeNull(); - - const postCall = apiFetch.mock.calls.find( - ([, opts]) => (opts as { method?: string })?.method === "POST", - ); - expect(postCall?.[0]).toContain("/v1/repos/acme/widgets/activation"); - }); - - it("surfaces the error message inline when activation fails, without touching the preview data", async () => { - apiFetch.mockResolvedValueOnce({ ok: true, data: BASE_PREVIEW }); - render(); - await waitFor(() => expect(screen.getByText(BASE_PREVIEW.summary)).toBeTruthy()); - - apiFetch.mockResolvedValueOnce({ ok: false, message: "403 Forbidden" }); - - fireEvent.click(screen.getByRole("button", { name: /enable advisory mode/i })); - - await waitFor(() => expect(screen.getByText("403 Forbidden")).toBeTruthy()); - // Still showing the previously-loaded preview, unchanged. - expect(screen.getByText(BASE_PREVIEW.summary)).toBeTruthy(); + expect(screen.getByText(/Not yet enabled/i)).toBeTruthy(); + expect(document.body.textContent).toContain("gate.checkMode: required"); }); it("falls back to a manual owner/repo entry when no repos are registered yet", () => { diff --git a/apps/loopover-ui/src/components/site/app-panels/activation-preview.tsx b/apps/loopover-ui/src/components/site/app-panels/activation-preview.tsx index cad5d91a17..cc3c66d232 100644 --- a/apps/loopover-ui/src/components/site/app-panels/activation-preview.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/activation-preview.tsx @@ -1,4 +1,4 @@ -import { CheckCircle2, Loader2, Rocket } from "lucide-react"; +import { CheckCircle2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { StatusPill, type Status } from "@/components/site/control-primitives"; @@ -34,16 +34,6 @@ type ActivationPreviewResponse = { summary: string; }; -type ActivationResponse = { - repoFullName: string; - reviewCheckMode: string; - linkedIssueGateMode: string; - duplicatePrGateMode: string; - qualityGateMode: string; -}; - -type Message = { kind: "ok" | "err"; text: string }; - const SEVERITY_TONE: Record = { info: "info", warning: "warn", @@ -57,10 +47,13 @@ function repoApiBase(repoFullName: string): string | null { } /** - * One-step maintainer activation demo (#701): loads GET /activation-preview for a repo (deterministic, - * no AI run) so a newly-installed maintainer sees concrete "here's what LoopOver would have surfaced" - * evidence, then a single action button posts /activation to turn on advisory mode. Mirrors the - * AiReviewSettings / MaintainerSettings repo-picker + load/save shape in this same file group. + * Maintainer activation demo (#701): loads GET /activation-preview for a repo (deterministic, no AI + * run) so a newly-installed maintainer sees concrete "here's what LoopOver would have surfaced" + * evidence. Purely informational — reviewCheckMode and every other gate field it reports on are + * config-as-code only now (Batch C, loopover#6444), so there is no longer a one-click action this + * panel can take on the maintainer's behalf; enabling the gate requires editing the repo's own + * .loopover.yml. Mirrors the AiReviewSettings / MaintainerSettings repo-picker + load shape in this + * same file group. */ export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr: string }> }) { const repoOptions = useMemo(() => extractPreviewRepoOptions(reviewability), [reviewability]); @@ -68,8 +61,6 @@ export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr const [preview, setPreview] = useState(null); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); - const [busy, setBusy] = useState(false); - const [message, setMessage] = useState(null); const base = repoApiBase(repoFullName); const hasRepos = repoOptions.length > 0; @@ -81,7 +72,6 @@ export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr setLoadError(null); return; } - setMessage(null); setLoadError(null); setLoading(true); const result = await apiFetch(`${apiBase}/activation-preview`, { @@ -102,28 +92,6 @@ export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr void load(); }, [load]); - async function activate() { - if (!base) return; - setBusy(true); - const result = await apiFetch(`${base}/activation`, { - method: "POST", - label: "Enable advisory mode", - credentials: "include", - headers: { Accept: "application/json", "Content-Type": "application/json" }, - }); - setBusy(false); - if (result.ok) { - // Reload first — `load()` clears any prior message, so the success message must be set after it settles. - await load(); - setMessage({ - kind: "ok", - text: "Advisory mode enabled. LoopOver will now surface guidance on new PRs.", - }); - } else { - setMessage({ kind: "err", text: result.message }); - } - } - return (

- See what LoopOver would have surfaced on this repo's recent pull requests, then enable - advisory mode in one step. Deterministic — never runs AI, never blocks a merge. + See what LoopOver would have surfaced on this repo's recent pull requests. Deterministic + — never runs AI, never blocks a merge.

{preview ? ( @@ -195,35 +163,15 @@ export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr : "Enter an installed repository to preview activation."}

) : preview ? ( - void activate()} - /> + ) : null} - - - {message?.text ?? ""} -
); } -function ActivationPreviewBody({ - preview, - busy, - onActivate, -}: { - preview: ActivationPreviewResponse; - busy: boolean; - onActivate: () => void; -}) { +function ActivationPreviewBody({ preview }: { preview: ActivationPreviewResponse }) { return (

{preview.summary}

@@ -297,16 +245,11 @@ function ActivationPreviewBody({
{preview.recommendedAction === "enable_advisory" ? ( - + + Not yet enabled — set gate.checkMode: required (or{" "} + gate.enabled: true) in this repo's{" "} + .loopover.yml to turn on advisory mode. + ) : ( Advisory mode is already enabled diff --git a/apps/loopover-ui/src/components/site/app-panels/ai-review-settings.tsx b/apps/loopover-ui/src/components/site/app-panels/ai-review-settings.tsx index cdfa6416d6..b6c9a193bd 100644 --- a/apps/loopover-ui/src/components/site/app-panels/ai-review-settings.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/ai-review-settings.tsx @@ -8,6 +8,12 @@ import { extractPreviewRepoOptions, splitRepoFullName } from "@/lib/maintainer-s type AiReviewMode = "off" | "advisory" | "block"; type AiProvider = "anthropic" | "openai"; +const MODE_COPY: Record = { + off: "off — no AI review", + advisory: "advisory — AI notes only", + block: "block — also blocks on a dual-model consensus defect", +}; + type RepoSettingsResponse = { aiReviewMode?: AiReviewMode; aiReviewByok?: boolean; @@ -33,8 +39,11 @@ function repoApiBase(repoFullName: string): string | null { const JSON_HEADERS = { Accept: "application/json", "Content-Type": "application/json" }; /** - * Maintainer self-serve AI review + BYOK key config. The provider key is write-only: it POSTs to the - * encrypted key endpoint and only the configured/last4 status is ever read back — the key is never rendered. + * Maintainer AI review status + self-serve BYOK key config. mode/byok/provider/model are config-as-code + * only now (Batch C, loopover#6444) -- read-only here, sourced from GET /settings (manifest-resolved), + * with guidance to edit the repo's own .loopover.yml gate.aiReview.* block to change them. The provider + * key management (still fully DB-backed) is unaffected: it POSTs to the encrypted key endpoint and only + * the configured/last4 status is ever read back — the key is never rendered. */ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr: string }> }) { const repoOptions = useMemo(() => extractPreviewRepoOptions(reviewability), [reviewability]); @@ -83,27 +92,6 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr: void load(); }, [load]); - async function saveConfig() { - if (!base) { - setMessage({ kind: "err", text: "Enter a repository as owner/repo." }); - return; - } - setBusy(true); - const result = await apiFetch(`${base}/ai-review`, { - method: "PUT", - label: "Save AI review config", - credentials: "include", - headers: JSON_HEADERS, - body: JSON.stringify({ mode, byok, provider, model: model.trim() || null }), - }); - setBusy(false); - setMessage( - result.ok - ? { kind: "ok", text: "AI review configuration saved." } - : { kind: "err", text: result.message }, - ); - } - async function saveKey() { if (!base) return; const trimmed = keyInput.trim(); @@ -176,9 +164,11 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr: AI review & BYOK

- Uses the operator's default reviewer by default. Bring your own Anthropic/OpenAI key for - a frontier-quality advisory write-up — your key, your provider account. Consensus - blocking always uses the default reviewer and only applies to confirmed contributors. + Mode, BYOK, provider, and model are set in this repo's own{" "} + .loopover.yml ( + gate.aiReview.*) now — shown below as read-only + status. Consensus blocking always uses the default reviewer and only applies to + confirmed contributors.

@@ -210,66 +200,26 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr: ) : null} - - - -
-
- -
diff --git a/apps/loopover-ui/src/components/site/app-panels/gate-ramp-confirm-dialog.tsx b/apps/loopover-ui/src/components/site/app-panels/gate-ramp-confirm-dialog.tsx deleted file mode 100644 index 6bde09e146..0000000000 --- a/apps/loopover-ui/src/components/site/app-panels/gate-ramp-confirm-dialog.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { - listRampGateTransitions, - RAMP_GATE_DISPLAY_LABELS, - type GateRampSettingsSlice, -} from "@/lib/gate-ramp"; - -type GateRampConfirmDialogProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - repoFullName: string; - settings: GateRampSettingsSlice; - busy: boolean; - onConfirm: () => void; -}; - -/** - * Confirmation gate before moving deterministic rules from advisory to blocking (#2218). Lists the exact - * sub-gates that will change so maintainers know what becomes merge-blocking. - */ -export function GateRampConfirmDialog({ - open, - onOpenChange, - repoFullName, - settings, - busy, - onConfirm, -}: GateRampConfirmDialogProps) { - const transitions = listRampGateTransitions(settings); - - return ( - - - - Enable blocking gate rules? - -
-

- This updates {repoFullName}{" "} - via the same settings mutation as the repository settings editor. Pull requests that - trip these gates may be blocked from merging once branch protection requires the - LoopOver check. -

- {transitions.length > 0 ? ( -
    - {transitions.map((entry) => ( -
  • - {RAMP_GATE_DISPLAY_LABELS[entry.key]}: {entry.from} → {entry.to} -
  • - ))} -
- ) : ( -

All ramp gates are already blocking.

- )} -
-
-
- - Cancel - { - event.preventDefault(); - onConfirm(); - }} - > - {busy ? "Saving…" : "Enable blocking"} - - -
-
- ); -} diff --git a/apps/loopover-ui/src/components/site/app-panels/gate-ramp-control.test.tsx b/apps/loopover-ui/src/components/site/app-panels/gate-ramp-control.test.tsx deleted file mode 100644 index 31d635deac..0000000000 --- a/apps/loopover-ui/src/components/site/app-panels/gate-ramp-control.test.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() })); -vi.mock("@/lib/api/request", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })); -vi.mock("@/lib/api/origin", () => ({ getApiOrigin: () => "https://api.test" })); - -const { toastSuccess, toastError } = vi.hoisted(() => ({ - toastSuccess: vi.fn(), - toastError: vi.fn(), -})); -vi.mock("sonner", () => ({ - toast: { - success: (...args: unknown[]) => toastSuccess(...args), - error: (...args: unknown[]) => toastError(...args), - }, -})); - -import { GateRampControl } from "@/components/site/app-panels/gate-ramp-control"; - -const REVIEWABILITY = [{ pr: "acme/widgets#1" }]; - -const ADVISORY_SETTINGS = { - reviewCheckMode: "required" as const, - gatePack: "gittensor" as const, - linkedIssueGateMode: "advisory" as const, - duplicatePrGateMode: "advisory" as const, - qualityGateMode: "advisory" as const, - qualityGateMinScore: null, - mergeReadinessGateMode: "off" as const, - manifestPolicyGateMode: "off" as const, - slopGateMode: "off" as const, - slopGateMinScore: null, - slopAiAdvisory: false, - autoLabelEnabled: true, - requireLinkedIssue: false, - commandAuthorization: {}, - autonomy: {}, - agentPaused: false, - agentDryRun: false, -}; - -const BLOCKING_SETTINGS = { - ...ADVISORY_SETTINGS, - linkedIssueGateMode: "block" as const, - duplicatePrGateMode: "block" as const, - qualityGateMode: "block" as const, -}; - -describe("GateRampControl (#2218)", () => { - beforeEach(() => { - apiFetch.mockReset(); - toastSuccess.mockReset(); - toastError.mockReset(); - }); - - it("loads settings and shows advisory phase with ramp switch off", async () => { - apiFetch.mockResolvedValue({ ok: true, data: ADVISORY_SETTINGS }); - render(); - - await waitFor(() => expect(screen.getByText(/Advisory/i)).toBeTruthy()); - const rampSwitch = screen.getByRole("switch", { name: /blocking enforcement/i }); - expect(rampSwitch.getAttribute("aria-checked")).toBe("false"); - expect(apiFetch).toHaveBeenCalledWith( - expect.stringContaining("/v1/repos/acme/widgets/settings"), - expect.objectContaining({ label: "Repository settings" }), - ); - }); - - it("opens confirm on switch, saves blocking ramp on confirm, and toasts success", async () => { - apiFetch.mockResolvedValueOnce({ ok: true, data: ADVISORY_SETTINGS }); - render(); - await waitFor(() => expect(screen.getByRole("switch")).toBeTruthy()); - - apiFetch.mockResolvedValueOnce({ ok: true, data: BLOCKING_SETTINGS }); - fireEvent.click(screen.getByRole("switch")); - expect(screen.getByText(/Enable blocking gate rules/i)).toBeTruthy(); - - fireEvent.click(screen.getByRole("button", { name: /^Enable blocking$/i })); - - await waitFor(() => - expect(toastSuccess).toHaveBeenCalledWith( - "Blocking mode enabled", - expect.objectContaining({ - description: expect.stringContaining("can now block merges"), - }), - ), - ); - - const putCall = apiFetch.mock.calls.find( - ([, opts]) => (opts as { method?: string })?.method === "PUT", - ); - expect(putCall?.[0]).toContain("/v1/repos/acme/widgets/settings"); - const body = JSON.parse(String((putCall?.[1] as { body?: string })?.body ?? "{}")); - expect(body.linkedIssueGateMode).toBe("block"); - expect(body.duplicatePrGateMode).toBe("block"); - expect(body.qualityGateMode).toBe("block"); - // gittensorLabel moved off the dashboard (Batch B, loopover#6443) -- no longer in the PUT payload. - expect(body.gittensorLabel).toBeUndefined(); - }); - - it("closes confirm without saving when cancel is clicked", async () => { - apiFetch.mockResolvedValueOnce({ ok: true, data: ADVISORY_SETTINGS }); - render(); - await waitFor(() => expect(screen.getByRole("switch")).toBeTruthy()); - - fireEvent.click(screen.getByRole("switch")); - fireEvent.click(screen.getByRole("button", { name: /cancel/i })); - - await waitFor(() => expect(screen.queryByText(/Enable blocking gate rules/i)).toBeNull()); - expect( - apiFetch.mock.calls.filter(([, opts]) => (opts as { method?: string })?.method === "PUT"), - ).toHaveLength(0); - }); - - it("toasts an error when the blocking ramp save fails", async () => { - apiFetch.mockResolvedValueOnce({ ok: true, data: ADVISORY_SETTINGS }); - render(); - await waitFor(() => expect(screen.getByRole("switch")).toBeTruthy()); - - fireEvent.click(screen.getByRole("switch")); - apiFetch.mockResolvedValueOnce({ ok: false, message: "403 Forbidden" }); - fireEvent.click(screen.getByRole("button", { name: /^Enable blocking$/i })); - - await waitFor(() => - expect(toastError).toHaveBeenCalledWith( - "Could not enable blocking", - expect.objectContaining({ description: "403 Forbidden" }), - ), - ); - expect(toastSuccess).not.toHaveBeenCalled(); - }); - - it("disables the switch when the gate is inactive (reviewCheckMode disabled)", async () => { - apiFetch.mockResolvedValue({ - ok: true, - data: { ...ADVISORY_SETTINGS, reviewCheckMode: "disabled" }, - }); - render(); - - await waitFor(() => expect(screen.getByText(/Gate off/i)).toBeTruthy()); - expect(screen.getByRole("switch")).toHaveProperty("disabled", true); - }); - - it("shows blocking phase with switch on and disabled when already ramped", async () => { - apiFetch.mockResolvedValue({ ok: true, data: BLOCKING_SETTINGS }); - render(); - - await waitFor(() => expect(screen.getByText(/Blocking/i)).toBeTruthy()); - const rampSwitch = screen.getByRole("switch"); - expect(rampSwitch.getAttribute("aria-checked")).toBe("true"); - expect(rampSwitch).toHaveProperty("disabled", true); - }); - - it("shows a no-repos hint and skips the load call when reviewability is empty", () => { - render(); - expect(screen.getByText(/Enter an installed repository to manage the gate ramp/i)).toBeTruthy(); - expect(apiFetch).not.toHaveBeenCalled(); - }); - - it("surfaces the load error via StateBoundary when the settings fetch fails", async () => { - apiFetch.mockResolvedValue({ ok: false, message: "500 Internal Server Error" }); - render(); - - await waitFor(() => - expect(screen.getByText(/Couldn't load repository settings/i)).toBeTruthy(), - ); - }); -}); diff --git a/apps/loopover-ui/src/components/site/app-panels/gate-ramp-control.tsx b/apps/loopover-ui/src/components/site/app-panels/gate-ramp-control.tsx deleted file mode 100644 index cc2d48e546..0000000000 --- a/apps/loopover-ui/src/components/site/app-panels/gate-ramp-control.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { Loader2, ShieldAlert } from "lucide-react"; -import { useCallback, useEffect, useId, useMemo, useState } from "react"; -import { toast } from "sonner"; - -import { StatusPill } from "@/components/site/control-primitives"; -import { GateRampConfirmDialog } from "@/components/site/app-panels/gate-ramp-confirm-dialog"; -import { StateBoundary } from "@/components/site/state-views"; -import { Switch } from "@/components/ui/switch"; -import { apiFetch } from "@/lib/api/request"; -import { getApiOrigin } from "@/lib/api/origin"; -import { - buildBlockingRampPatch, - summarizeGateRamp, - type GateRampSettingsSlice, -} from "@/lib/gate-ramp"; -import { - buildMaintainerSettingsSavePayload, - type MaintainerSettingsEditable, -} from "@/lib/maintainer-settings-editable"; -import { extractPreviewRepoOptions, splitRepoFullName } from "@/lib/maintainer-settings-preview"; - -function repoApiBase(repoFullName: string): string | null { - const target = splitRepoFullName(repoFullName); - if (!target) return null; - return `${getApiOrigin().replace(/\/$/, "")}/v1/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`; -} - -const JSON_HEADERS = { Accept: "application/json", "Content-Type": "application/json" }; -const FIELD_CLASS = - "mt-1 min-h-10 w-full rounded-token border border-border bg-background/70 px-3 py-2 font-mono text-token-sm text-foreground outline-none transition-colors focus:border-mint"; -const LABEL_CLASS = "font-mono text-token-2xs uppercase tracking-wider text-muted-foreground"; - -function normalizeLoadedSettings(data: MaintainerSettingsEditable): MaintainerSettingsEditable { - return { - ...data, - autonomy: data.autonomy ?? {}, - agentPaused: data.agentPaused ?? false, - agentDryRun: data.agentDryRun ?? false, - }; -} - -/** - * One-click advisory → blocking ramp for the maintainer onboarding surface (#2218). Loads GET /settings, - * reflects the current ramp phase, and on confirm merges the blocking patch through PUT /settings (same path - * as maintainer-settings.tsx). AlertDialog gates the destructive flip; sonner toasts report save outcomes. - */ -export function GateRampControl({ reviewability }: { reviewability: Array<{ pr: string }> }) { - const repoOptions = useMemo(() => extractPreviewRepoOptions(reviewability), [reviewability]); - const [repoFullName, setRepoFullName] = useState(repoOptions[0] ?? ""); - const [settings, setSettings] = useState(null); - const [loading, setLoading] = useState(false); - const [loadError, setLoadError] = useState(null); - const [busy, setBusy] = useState(false); - const [confirmOpen, setConfirmOpen] = useState(false); - - const switchId = useId(); - const base = repoApiBase(repoFullName); - const hasRepos = repoOptions.length > 0; - - const rampSlice: GateRampSettingsSlice | null = settings - ? { - reviewCheckMode: settings.reviewCheckMode, - linkedIssueGateMode: settings.linkedIssueGateMode, - duplicatePrGateMode: settings.duplicatePrGateMode, - qualityGateMode: settings.qualityGateMode, - } - : null; - - const summary = rampSlice ? summarizeGateRamp(rampSlice) : null; - - const load = useCallback(async () => { - const apiBase = repoApiBase(repoFullName); - if (!apiBase) { - setSettings(null); - setLoadError(null); - return; - } - setLoadError(null); - setLoading(true); - const result = await apiFetch(`${apiBase}/settings`, { - label: "Repository settings", - credentials: "include", - silentStatus: true, - }); - setSettings(result.ok ? normalizeLoadedSettings(result.data) : null); - if (!result.ok) setLoadError(result.message); - setLoading(false); - }, [repoFullName]); - - useEffect(() => { - void load(); - }, [load]); - - async function saveBlockingRamp() { - if (!base || !settings) return; - setBusy(true); - const payload = buildMaintainerSettingsSavePayload(settings, buildBlockingRampPatch()); - const result = await apiFetch(`${base}/settings`, { - method: "PUT", - label: "Ramp to blocking", - credentials: "include", - headers: JSON_HEADERS, - body: JSON.stringify(payload), - }); - setBusy(false); - setConfirmOpen(false); - if (result.ok) { - setSettings(normalizeLoadedSettings(result.data)); - toast.success("Blocking mode enabled", { - description: "Linked-issue, duplicate-PR, and quality gates can now block merges.", - }); - } else { - toast.error("Could not enable blocking", { description: result.message }); - } - } - - function handleSwitchChange(checked: boolean) { - if (!summary?.canRampToBlocking || !checked) return; - setConfirmOpen(true); - } - - const switchChecked = summary?.isBlocking ?? false; - const switchDisabled = - busy || loading || !summary || !summary.canRampToBlocking || summary.isBlocking; - - return ( -
-
-
-

- Gate ramp control -

-

- After advisory mode is on, ramp deterministic gate rules to blocking in one action — - with confirmation before merges can be held. -

-
- {summary ? ( - - {summary.label} - - ) : null} -
- - - -
- - {!base ? ( -

- {hasRepos - ? "Settings are unavailable for this repository." - : "Enter an installed repository to manage the gate ramp."} -

- ) : summary && settings ? ( -
-

{summary.description}

- -
-
- -
- -

- {summary.canRampToBlocking - ? "Off — advisory only. Turn on to block merges when gate findings fire." - : summary.isBlocking - ? "On — deterministic gates are blocking." - : "Unavailable until advisory mode is enabled above."} -

-
-
-
- {busy ? ( - - ) : null} - -
-
-
- ) : null} -
-
- - {settings && rampSlice ? ( - void saveBlockingRamp()} - /> - ) : null} -
- ); -} diff --git a/apps/loopover-ui/src/components/site/app-panels/maintainer-panel.tsx b/apps/loopover-ui/src/components/site/app-panels/maintainer-panel.tsx index 6b4278e371..29c41aea08 100644 --- a/apps/loopover-ui/src/components/site/app-panels/maintainer-panel.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/maintainer-panel.tsx @@ -24,7 +24,6 @@ import { ContributorQualityTable } from "@/components/site/app-panels/contributo import type { MaintainerTopContributor } from "@/components/site/app-panels/contributor-quality-table-model"; import { GateOutcomeCard } from "@/components/site/app-panels/gate-outcome-card"; import type { GateOutcomeCardData } from "@/components/site/app-panels/gate-outcome-card-model"; -import { GateRampControl } from "@/components/site/app-panels/gate-ramp-control"; import { McpToolUsageCard, type McpToolUsageSummary, @@ -456,7 +455,10 @@ function MaintainerDashboardView({ - + {/* GateRampControl (advisory -> blocking one-click ramp) was removed here: it ramped + linkedIssueGateMode/duplicatePrGateMode/qualityGateMode (plus reviewCheckMode for its + on/off check) all config-as-code only now (Batch C, loopover#6444) -- writing them via + PUT /settings is a silent no-op, so the switch had nothing left to do. */}

Configure exactly what LoopOver enforces and surfaces on this repo — gate modes, - anti-slop, labels, public output, and who can run each command. Changes are audited. + anti-slop, labels, public output, and who can run each command. Changes are audited. The + review-agent check itself (on/off) is config-as-code only now — set{" "} + gate.checkMode in this repo's{" "} + .loopover.yml.

- {settings ? ( - - gate {settings.reviewCheckMode === "disabled" ? "off" : "enabled"} - - ) : null}