Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ActivationPreview reviewability={REVIEWABILITY} />);
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(<ActivationPreview reviewability={REVIEWABILITY} />);
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", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<ActivationSeverity, Status> = {
info: "info",
warning: "warn",
Expand All @@ -57,19 +47,20 @@ 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]);
const [repoFullName, setRepoFullName] = useState(repoOptions[0] ?? "");
const [preview, setPreview] = useState<ActivationPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<Message | null>(null);

const base = repoApiBase(repoFullName);
const hasRepos = repoOptions.length > 0;
Expand All @@ -81,7 +72,6 @@ export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr
setLoadError(null);
return;
}
setMessage(null);
setLoadError(null);
setLoading(true);
const result = await apiFetch<ActivationPreviewResponse>(`${apiBase}/activation-preview`, {
Expand All @@ -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<ActivationResponse>(`${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 (
<section
className="rounded-token border-hairline bg-card p-5"
Expand All @@ -135,8 +103,8 @@ export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr
Instant activation preview
</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
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.
</p>
</div>
{preview ? (
Expand Down Expand Up @@ -195,35 +163,15 @@ export function ActivationPreview({ reviewability }: { reviewability: Array<{ pr
: "Enter an installed repository to preview activation."}
</p>
) : preview ? (
<ActivationPreviewBody
preview={preview}
busy={busy}
onActivate={() => void activate()}
/>
<ActivationPreviewBody preview={preview} />
) : null}
</StateBoundary>
</div>

<span
role="status"
aria-live="polite"
className={`mt-4 block text-token-xs ${message ? (message.kind === "ok" ? "text-mint" : "text-warning") : "sr-only"}`}
>
{message?.text ?? ""}
</span>
</section>
);
}

function ActivationPreviewBody({
preview,
busy,
onActivate,
}: {
preview: ActivationPreviewResponse;
busy: boolean;
onActivate: () => void;
}) {
function ActivationPreviewBody({ preview }: { preview: ActivationPreviewResponse }) {
return (
<div className="space-y-4">
<p className="text-token-sm text-foreground/90">{preview.summary}</p>
Expand Down Expand Up @@ -297,16 +245,11 @@ function ActivationPreviewBody({

<div className="flex flex-wrap items-center gap-3">
{preview.recommendedAction === "enable_advisory" ? (
<button
type="button"
disabled={busy}
aria-busy={busy}
onClick={onActivate}
className="inline-flex items-center gap-2 rounded-token border border-mint/40 bg-mint px-3 py-2 text-token-xs font-medium text-primary-foreground transition-all hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Rocket className="size-3.5" />}
Enable advisory mode
</button>
<span className="inline-flex items-center gap-2 rounded-token border-hairline bg-background/40 px-3 py-2 text-token-xs text-muted-foreground">
Not yet enabled — set <code className="font-mono">gate.checkMode: required</code> (or{" "}
<code className="font-mono">gate.enabled: true</code>) in this repo's{" "}
<code className="font-mono">.loopover.yml</code> to turn on advisory mode.
</span>
) : (
<span className="inline-flex items-center gap-2 rounded-token border border-success/35 bg-success/10 px-3 py-2 text-token-xs text-success">
<CheckCircle2 className="size-3.5" /> Advisory mode is already enabled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import { extractPreviewRepoOptions, splitRepoFullName } from "@/lib/maintainer-s
type AiReviewMode = "off" | "advisory" | "block";
type AiProvider = "anthropic" | "openai";

const MODE_COPY: Record<AiReviewMode, string> = {
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;
Expand All @@ -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]);
Expand Down Expand Up @@ -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<RepoSettingsResponse>(`${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();
Expand Down Expand Up @@ -176,9 +164,11 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr:
AI review &amp; BYOK
</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
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{" "}
<code className="font-mono">.loopover.yml</code> (
<code className="font-mono">gate.aiReview.*</code>) now — shown below as read-only
status. Consensus blocking always uses the default reviewer and only applies to
confirmed contributors.
</p>
</div>
<StatusPill status={mode === "off" ? "info" : mode === "block" ? "warn" : "ready"}>
Expand Down Expand Up @@ -210,66 +200,26 @@ export function AiReviewSettings({ reviewability }: { reviewability: Array<{ pr:
) : null}
</label>

<label className="block">
<span className={labelClass}>Mode</span>
<select
value={mode}
onChange={(event) => setMode(event.target.value as AiReviewMode)}
className={fieldClass}
>
<option value="off">off — no AI review</option>
<option value="advisory">advisory — AI notes only</option>
<option value="block">block — also block on a dual-model consensus defect</option>
</select>
</label>

<label className="flex items-center gap-2 text-token-sm">
<input
type="checkbox"
checked={byok}
onChange={(event) => setByok(event.target.checked)}
className="size-4 accent-mint"
/>
<span>Use my own provider key (BYOK) for the advisory write-up</span>
</label>

<div className="grid grid-cols-2 gap-3">
<label className="block">
<div>
<span className={labelClass}>Mode</span>
<p className="mt-1 text-token-sm text-foreground/90">{MODE_COPY[mode]}</p>
</div>
<div>
<span className={labelClass}>BYOK</span>
<p className="mt-1 text-token-sm text-foreground/90">{byok ? "on" : "off"}</p>
</div>
<div>
<span className={labelClass}>Provider</span>
<select
value={provider}
onChange={(event) => setProvider(event.target.value as AiProvider)}
className={fieldClass}
>
<option value="anthropic">Anthropic (Claude)</option>
<option value="openai">OpenAI (GPT)</option>
</select>
</label>
<label className="block">
<span className={labelClass}>Model (optional)</span>
<input
value={model}
onChange={(event) => setModel(event.target.value)}
placeholder="default"
className={fieldClass}
/>
</label>
<p className="mt-1 text-token-sm text-foreground/90">
{provider === "anthropic" ? "Anthropic (Claude)" : "OpenAI (GPT)"}
</p>
</div>
<div>
<span className={labelClass}>Model</span>
<p className="mt-1 text-token-sm text-foreground/90">{model || "default"}</p>
</div>
</div>

<button
type="button"
disabled={busy || loading || !base}
aria-busy={busy}
onClick={() => void saveConfig()}
className="inline-flex items-center gap-2 rounded-token border border-mint/40 bg-mint px-3 py-2 text-token-xs font-medium text-primary-foreground transition-all hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{busy || loading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)}
{loading ? "Loading…" : "Save configuration"}
</button>
</div>

<div className="space-y-4 rounded-token border-hairline bg-background/40 p-4">
Expand Down
Loading
Loading