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."}
@@ -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.
- 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.
-
- {hasRepos
- ? "Settings are unavailable for this repository."
- : "Enter an installed repository to manage the gate ramp."}
-
- ) : summary && settings ? (
-
-
{summary.description}
-
-
-
-
-
-
- Blocking enforcement
-
-
- {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}
diff --git a/apps/loopover-ui/src/lib/gate-ramp.test.ts b/apps/loopover-ui/src/lib/gate-ramp.test.ts
deleted file mode 100644
index 7dff6d7c6d..0000000000
--- a/apps/loopover-ui/src/lib/gate-ramp.test.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import {
- buildBlockingRampPatch,
- deriveGateRampPhase,
- isBlockingRampComplete,
- isGateRampActive,
- listRampGateTransitions,
- summarizeGateRamp,
-} from "@/lib/gate-ramp";
-
-const ADVISORY_SLICE = {
- reviewCheckMode: "required" as const,
- linkedIssueGateMode: "advisory" as const,
- duplicatePrGateMode: "advisory" as const,
- qualityGateMode: "advisory" as const,
-};
-
-const BLOCKING_SLICE = {
- ...ADVISORY_SLICE,
- linkedIssueGateMode: "block" as const,
- duplicatePrGateMode: "block" as const,
- qualityGateMode: "block" as const,
-};
-
-describe("gate-ramp helpers (#2218)", () => {
- it("treats reviewCheckMode disabled as inactive", () => {
- const disabled = { ...ADVISORY_SLICE, reviewCheckMode: "disabled" as const };
- expect(isGateRampActive(disabled)).toBe(false);
- expect(deriveGateRampPhase(disabled)).toBe("inactive");
- });
-
- it("treats reviewCheckMode visible or required as active", () => {
- expect(isGateRampActive(ADVISORY_SLICE)).toBe(true);
- expect(isGateRampActive({ ...ADVISORY_SLICE, reviewCheckMode: "visible" })).toBe(true);
- });
-
- it("detects advisory and blocking phases from the ramp trio", () => {
- expect(deriveGateRampPhase(ADVISORY_SLICE)).toBe("advisory");
- expect(isBlockingRampComplete(ADVISORY_SLICE)).toBe(false);
- expect(deriveGateRampPhase(BLOCKING_SLICE)).toBe("blocking");
- expect(isBlockingRampComplete(BLOCKING_SLICE)).toBe(true);
- });
-
- it("summarizeGateRamp exposes ramp affordances only in advisory phase", () => {
- const inactive = summarizeGateRamp({ ...ADVISORY_SLICE, reviewCheckMode: "disabled" });
- expect(inactive.label).toBe("Gate off");
- expect(inactive.canRampToBlocking).toBe(false);
- expect(inactive.isBlocking).toBe(false);
-
- const advisory = summarizeGateRamp(ADVISORY_SLICE);
- expect(advisory.label).toBe("Advisory");
- expect(advisory.canRampToBlocking).toBe(true);
- expect(advisory.isBlocking).toBe(false);
-
- const blocking = summarizeGateRamp(BLOCKING_SLICE);
- expect(blocking.label).toBe("Blocking");
- expect(blocking.canRampToBlocking).toBe(false);
- expect(blocking.isBlocking).toBe(true);
- });
-
- it("buildBlockingRampPatch flips the deterministic trio to block", () => {
- expect(buildBlockingRampPatch()).toEqual({
- linkedIssueGateMode: "block",
- duplicatePrGateMode: "block",
- qualityGateMode: "block",
- });
- });
-
- it("listRampGateTransitions lists all three when none are blocking yet", () => {
- const transitions = listRampGateTransitions(ADVISORY_SLICE);
- expect(transitions).toHaveLength(3);
- expect(transitions).toEqual([
- { key: "linkedIssueGateMode", from: "advisory", to: "block" },
- { key: "duplicatePrGateMode", from: "advisory", to: "block" },
- { key: "qualityGateMode", from: "advisory", to: "block" },
- ]);
- });
-
- it("listRampGateTransitions omits gates already at the target mode", () => {
- const mixed = { ...ADVISORY_SLICE, duplicatePrGateMode: "block" as const };
- const transitions = listRampGateTransitions(mixed);
- expect(transitions).toHaveLength(2);
- expect(transitions.map((t) => t.key)).toEqual(["linkedIssueGateMode", "qualityGateMode"]);
- });
-
- it("listRampGateTransitions is empty once every gate is already blocking", () => {
- expect(listRampGateTransitions(BLOCKING_SLICE)).toEqual([]);
- });
-});
diff --git a/apps/loopover-ui/src/lib/gate-ramp.ts b/apps/loopover-ui/src/lib/gate-ramp.ts
deleted file mode 100644
index 2f82097df6..0000000000
--- a/apps/loopover-ui/src/lib/gate-ramp.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * Advisory → blocking ramp helpers for the maintainer onboarding surface (#2218). Mirrors the deterministic
- * gate trio that `ActivationPreview`'s POST /activation enables in advisory mode; blocking ramps those same
- * fields to `block` via the existing PUT /settings merge path (maintainer-settings.tsx).
- */
-
-import type { GateMode } from "@/lib/maintainer-settings-editable";
-
-export type { GateMode };
-
-/** The three deterministic sub-gates flipped together during the ramp. */
-export const RAMP_DETERMINISTIC_GATE_KEYS = [
- "linkedIssueGateMode",
- "duplicatePrGateMode",
- "qualityGateMode",
-] as const;
-
-export type RampDeterministicGateKey = (typeof RAMP_DETERMINISTIC_GATE_KEYS)[number];
-
-export type GateRampSettingsSlice = {
- // #4618/#5373: reviewCheckMode is the sole writable authority for whether the check-run publishes at
- // all (the "disabled" | "visible" | "required" trio) -- the prior computed gateCheckMode field this
- // helper set once mirrored has since been removed entirely, so ramp activity is gated on this alone.
- reviewCheckMode: "required" | "visible" | "disabled";
-} & Record;
-
-export type GateRampPhase = "inactive" | "advisory" | "blocking";
-
-export type GateRampSummary = {
- phase: GateRampPhase;
- /** Human label for the ramp pill (inactive / advisory / blocking). */
- label: string;
- /** Short helper copy under the switch. */
- description: string;
- /** Whether the maintainer can attempt the advisory → blocking transition. */
- canRampToBlocking: boolean;
- /** Whether blocking is already fully engaged for the ramp trio. */
- isBlocking: boolean;
-};
-
-const PHASE_LABEL: Record = {
- inactive: "Gate off",
- advisory: "Advisory",
- blocking: "Blocking",
-};
-
-const PHASE_DESCRIPTION: Record = {
- inactive:
- "Enable advisory mode in the activation preview above before ramping deterministic rules to blocking.",
- advisory:
- "Deterministic linked-issue, duplicate-PR, and quality gates surface guidance without blocking merges. Flip to blocking when you are ready to enforce.",
- blocking:
- "Linked-issue, duplicate-PR, and quality gates can block merges when findings fire. Re-tune individual gates in repository settings below.",
-};
-
-/** Whether the LoopOver review-agent check is actively publishing at all. */
-export function isGateRampActive(settings: GateRampSettingsSlice): boolean {
- return settings.reviewCheckMode !== "disabled";
-}
-
-/** True when every ramp deterministic sub-gate is set to block. */
-export function isBlockingRampComplete(settings: GateRampSettingsSlice): boolean {
- return RAMP_DETERMINISTIC_GATE_KEYS.every((key) => settings[key] === "block");
-}
-
-/** Derive the maintainer-facing ramp phase from loaded repository settings. */
-export function deriveGateRampPhase(settings: GateRampSettingsSlice): GateRampPhase {
- if (!isGateRampActive(settings)) return "inactive";
- if (isBlockingRampComplete(settings)) return "blocking";
- return "advisory";
-}
-
-export function summarizeGateRamp(settings: GateRampSettingsSlice): GateRampSummary {
- const phase = deriveGateRampPhase(settings);
- const isBlocking = phase === "blocking";
- return {
- phase,
- label: PHASE_LABEL[phase],
- description: PHASE_DESCRIPTION[phase],
- canRampToBlocking: phase === "advisory",
- isBlocking,
- };
-}
-
-/** Patch applied on confirm: only the ramp trio moves to block; everything else is preserved by PUT merge. */
-export function buildBlockingRampPatch(): Pick {
- return {
- linkedIssueGateMode: "block",
- duplicatePrGateMode: "block",
- qualityGateMode: "block",
- };
-}
-
-/** List gate keys that would change when ramping (for confirm-dialog copy). */
-export function listRampGateTransitions(
- settings: GateRampSettingsSlice,
-): Array<{ key: RampDeterministicGateKey; from: GateMode; to: GateMode }> {
- const patch = buildBlockingRampPatch();
- return RAMP_DETERMINISTIC_GATE_KEYS.map((key) => ({
- key,
- from: settings[key],
- to: patch[key],
- })).filter((entry) => entry.from !== entry.to);
-}
-
-/** Friendly labels for confirm-dialog rows. */
-export const RAMP_GATE_DISPLAY_LABELS: Record = {
- linkedIssueGateMode: "Linked issue gate",
- duplicatePrGateMode: "Duplicate PR gate",
- qualityGateMode: "Quality / readiness gate",
-};
diff --git a/apps/loopover-ui/src/lib/maintainer-settings-editable.test.ts b/apps/loopover-ui/src/lib/maintainer-settings-editable.test.ts
index a91f5966af..794ec7942c 100644
--- a/apps/loopover-ui/src/lib/maintainer-settings-editable.test.ts
+++ b/apps/loopover-ui/src/lib/maintainer-settings-editable.test.ts
@@ -7,12 +7,7 @@ import {
} from "@/lib/maintainer-settings-editable";
const SETTINGS: MaintainerSettingsEditable = {
- reviewCheckMode: "required",
gatePack: "gittensor",
- linkedIssueGateMode: "advisory",
- duplicatePrGateMode: "advisory",
- qualityGateMode: "advisory",
- qualityGateMinScore: null,
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
slopGateMode: "off",
@@ -30,19 +25,19 @@ describe("maintainer-settings-editable (#2218)", () => {
it("buildMaintainerSettingsSavePayload includes every editable key, verbatim, with no patch", () => {
const payload = buildMaintainerSettingsSavePayload(SETTINGS);
expect(Object.keys(payload).sort()).toEqual([...MAINTAINER_SETTINGS_EDITABLE_KEYS].sort());
- expect(payload.linkedIssueGateMode).toBe("advisory");
+ expect(payload.mergeReadinessGateMode).toBe("off");
expect(payload.autoLabelEnabled).toBe(true);
});
it("buildMaintainerSettingsSavePayload merges a partial patch over the base settings", () => {
const payload = buildMaintainerSettingsSavePayload(SETTINGS, {
- linkedIssueGateMode: "block",
- duplicatePrGateMode: "block",
+ mergeReadinessGateMode: "block",
+ manifestPolicyGateMode: "block",
});
- expect(payload.linkedIssueGateMode).toBe("block");
- expect(payload.duplicatePrGateMode).toBe("block");
+ expect(payload.mergeReadinessGateMode).toBe("block");
+ expect(payload.manifestPolicyGateMode).toBe("block");
// Untouched fields pass through unchanged.
- expect(payload.qualityGateMode).toBe("advisory");
+ expect(payload.slopGateMode).toBe("off");
expect(payload.autoLabelEnabled).toBe(true);
});
diff --git a/apps/loopover-ui/src/lib/maintainer-settings-editable.ts b/apps/loopover-ui/src/lib/maintainer-settings-editable.ts
index 8f6aedcd39..d9ae53e708 100644
--- a/apps/loopover-ui/src/lib/maintainer-settings-editable.ts
+++ b/apps/loopover-ui/src/lib/maintainer-settings-editable.ts
@@ -17,14 +17,7 @@ export type AgentActionClass =
"review" | "request_changes" | "approve" | "merge" | "close" | "label";
export type MaintainerSettingsEditable = {
- // #4618/#5373: a prior gateCheckMode field was a deprecated computed read-back, since removed entirely --
- // reviewCheckMode is the real, writable authority for whether the review-agent check-run publishes.
- reviewCheckMode: "required" | "visible" | "disabled";
gatePack: "gittensor" | "oss-anti-slop";
- linkedIssueGateMode: GateMode;
- duplicatePrGateMode: GateMode;
- qualityGateMode: GateMode;
- qualityGateMinScore: number | null;
mergeReadinessGateMode: GateMode;
manifestPolicyGateMode: GateMode;
slopGateMode: GateMode;
@@ -33,6 +26,8 @@ export type MaintainerSettingsEditable = {
autoLabelEnabled: boolean;
// #6443: gittensorLabel/createMissingLabel removed -- no longer DB-backed, config-as-code only via
// .loopover.yml's settings: block now (the dashboard can no longer write them).
+ // #6444: reviewCheckMode/linkedIssueGateMode/duplicatePrGateMode/qualityGateMode/qualityGateMinScore
+ // removed for the same reason -- config-as-code only via .loopover.yml's gate.* block now.
requireLinkedIssue: boolean;
commandAuthorization: CommandAuthorization;
autonomy: Partial>;
@@ -44,12 +39,7 @@ export type MaintainerSettingsEditable = {
// The maintainer-editable subset, sent verbatim to PUT /settings (which merges onto current settings).
export const MAINTAINER_SETTINGS_EDITABLE_KEYS: Array = [
- "reviewCheckMode",
"gatePack",
- "linkedIssueGateMode",
- "duplicatePrGateMode",
- "qualityGateMode",
- "qualityGateMinScore",
"mergeReadinessGateMode",
"manifestPolicyGateMode",
"slopGateMode",
diff --git a/migrations/0162_drop_batch_c_config_as_code_columns.sql b/migrations/0162_drop_batch_c_config_as_code_columns.sql
new file mode 100644
index 0000000000..2697dff4e3
--- /dev/null
+++ b/migrations/0162_drop_batch_c_config_as_code_columns.sql
@@ -0,0 +1,17 @@
+-- Config-as-code migration (Batch C, loopover#6444, epic #6440): reviewCheckMode, linkedIssueGateMode,
+-- duplicatePrGateMode, qualityGateMode, qualityGateMinScore, selfAuthoredLinkedIssueGateMode,
+-- aiReviewMode, aiReviewByok, aiReviewProvider, aiReviewModel, and aiReviewAllAuthors already resolved
+-- correctly from .loopover.yml's settings.*/gate.* blocks; the repository_settings DB columns were a
+-- redundant second source of truth resolveEffectiveSettings's manifest overlay already fully shadowed.
+-- SQLite 3.35+ / D1 supports DROP COLUMN directly (same precedent as 0122/0146/0150/0157/0158/0159).
+ALTER TABLE repository_settings DROP COLUMN review_check_mode;
+ALTER TABLE repository_settings DROP COLUMN linked_issue_gate_mode;
+ALTER TABLE repository_settings DROP COLUMN duplicate_pr_gate_mode;
+ALTER TABLE repository_settings DROP COLUMN quality_gate_mode;
+ALTER TABLE repository_settings DROP COLUMN quality_gate_min_score;
+ALTER TABLE repository_settings DROP COLUMN self_authored_linked_issue_gate_mode;
+ALTER TABLE repository_settings DROP COLUMN ai_review_mode;
+ALTER TABLE repository_settings DROP COLUMN ai_review_byok;
+ALTER TABLE repository_settings DROP COLUMN ai_review_provider;
+ALTER TABLE repository_settings DROP COLUMN ai_review_model;
+ALTER TABLE repository_settings DROP COLUMN ai_review_all_authors;
diff --git a/src/api/routes.ts b/src/api/routes.ts
index f39b816057..f64f05c55a 100644
--- a/src/api/routes.ts
+++ b/src/api/routes.ts
@@ -264,7 +264,7 @@ import { buildSlopAssessment, SLOP_RUBRIC_MARKDOWN } from "../signals/slop";
import { buildPredictedGateVerdict } from "../rules/predicted-gate";
import { computeContributorCalibration } from "../review/predicted-gate-calibration-ledger";
import { buildFocusManifestValidation } from "../services/focus-manifest-validation";
-import { buildMaintainerActivationPreview, recommendedAdvisoryActivationSettings } from "../services/maintainer-activation";
+import { buildMaintainerActivationPreview } from "../services/maintainer-activation";
import { buildRepoOutcomeCalibration } from "../services/outcome-calibration";
import { loadGatePrecisionReport } from "../services/gate-precision";
import { computeOpsStats, isOpsEnabled, resolveOpsManifestOverride } from "../review/ops-wire";
@@ -281,7 +281,7 @@ import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from ".
import { buildMaintainerSlopDuplicateTrend, SLOP_DUPLICATE_TREND_SNAPSHOT_LIMIT } from "../services/maintainer-slop-duplicate-trend";
import { buildGateOutcomeBreakdown, GATE_OUTCOME_BREAKDOWN_WINDOW_DAYS } from "../services/gate-outcome-breakdown";
import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics";
-import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES, normalizeReadinessGateMode, resolveEffectiveSettings } from "../signals/focus-manifest";
+import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES, resolveEffectiveSettings } from "../signals/focus-manifest";
import { resolveRepositorySettings } from "../settings/repository-settings";
import { loadPublicRepoFocusManifest, loadRepoFocusManifest, upsertRepoFocusManifest } from "../signals/focus-manifest-loader";
import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack";
@@ -666,21 +666,12 @@ const agentPlanSchema = z
const agentExplainBlockersSchema = z.union([localBranchAnalysisSchema, agentPlanSchema]);
+// reviewCheckMode/linkedIssueGateMode/duplicatePrGateMode/qualityGateMode/qualityGateMinScore/
+// aiReviewMode/aiReviewByok/aiReviewProvider/aiReviewModel/aiReviewAllAuthors removed from this write
+// schema (Batch C, loopover#6444) -- config-as-code only via .loopover.yml's gate.* block now;
+// upsertRepositorySettings no longer has a DB column to write any of them into.
const repositorySettingsSchema = z.object({
- // #4618/#5373: this write schema never accepted a gateCheckMode field -- it was a deprecated computed
- // read-back of reviewCheckMode, removed from RepositorySettings entirely in #5373. Set reviewCheckMode
- // directly.
- reviewCheckMode: z.enum(["required", "visible", "disabled"]).default("disabled"),
gatePack: z.enum(["gittensor", "oss-anti-slop"]).default("gittensor"),
- linkedIssueGateMode: z.enum(["off", "advisory", "block"]).default("advisory"),
- duplicatePrGateMode: z.enum(["off", "advisory", "block"]).default("block"),
- qualityGateMode: z.enum(["off", "advisory", "block"]).default("advisory"),
- qualityGateMinScore: z.number().int().min(0).max(100).nullable().optional(),
- aiReviewMode: z.enum(["off", "advisory", "block"]).default("off"),
- aiReviewByok: z.boolean().default(false),
- aiReviewProvider: z.enum(["anthropic", "openai"]).nullable().optional(),
- aiReviewModel: z.string().trim().min(1).max(120).nullable().optional(),
- aiReviewAllAuthors: z.boolean().default(false),
aiReviewLowConfidenceDisposition: z.enum(["one_shot", "hold_for_review", "advisory_only"]).default("hold_for_review"),
closeOwnerAuthors: z.boolean().default(false),
autoLabelEnabled: z.boolean().default(true),
@@ -700,17 +691,14 @@ const repositorySettingsSchema = z.object({
// dedicated /ai-review + /ai-key routes) and the operator-only scoring internal (backfillEnabled). The
// handler loads current settings and merges, since upsertRepositorySettings defaults any absent field
// rather than preserving it.
+// reviewCheckMode/linkedIssueGateMode/duplicatePrGateMode/qualityGateMode/qualityGateMinScore/
+// selfAuthoredLinkedIssueGateMode removed from this write schema (Batch C, loopover#6444) --
+// config-as-code only via .loopover.yml's gate.* block now.
const maintainerSettingsSchema = z
.object({
- reviewCheckMode: z.enum(["required", "visible", "disabled"]),
gatePack: z.enum(["gittensor", "oss-anti-slop"]),
- linkedIssueGateMode: z.enum(["off", "advisory", "block"]),
- duplicatePrGateMode: z.enum(["off", "advisory", "block"]),
- qualityGateMode: z.enum(["off", "advisory", "block"]),
- qualityGateMinScore: z.number().int().min(0).max(100).nullable(),
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
manifestPolicyGateMode: z.enum(["off", "advisory", "block"]),
- selfAuthoredLinkedIssueGateMode: z.enum(["off", "advisory", "block"]),
linkedIssueSatisfactionGateMode: z.enum(["off", "advisory", "block"]),
// #6443: mergeTrainMode/gittensorLabel/blacklistLabel/createMissingLabel removed -- no longer DB-backed,
// config-as-code only via .loopover.yml's settings: block now.
@@ -738,20 +726,10 @@ const maintainerSettingsSchema = z
})
.partial();
-/** Readiness/quality can never hard-block a PR (buildQualityGateWarning is always advisory-severity;
- * isConfiguredGateBlocker has no branch for it) — downgrade a settings-write's `qualityGateMode: "block"` to
- * `"advisory"` here too, mirroring the same downgrade `.loopover.yml`'s `gate.readiness.mode` /
- * `settings.qualityGateMode` already get in normalizeReadinessGateMode, so the dashboard/API save path can
- * never persist a value that implies enforcement it doesn't have (#2267). Callers check for `undefined`
- * (a PATCH-style save that didn't touch this field) before calling — this only handles the defined case, so
- * the return type never needs `undefined` under `exactOptionalPropertyTypes`. The warnings array is scratch;
- * these routes have no warnings-response protocol. */
-function downgradeQualityGateMode(mode: "off" | "advisory" | "block"): "off" | "advisory" {
- /* v8 ignore next -- never null for a Zod-validated "off"|"advisory"|"block" input (normalizeReadinessGateMode's
- "must be one of" branch only fires for a value outside that set); the fallback only satisfies the shared
- parser's broader return type. */
- return (normalizeReadinessGateMode(mode, "qualityGateMode", []) as "off" | "advisory" | null) ?? "advisory";
-}
+// downgradeQualityGateMode (the settings-write-path "block" -> "advisory" downgrade for
+// qualityGateMode/#2267) was removed here: qualityGateMode is config-as-code only now (Batch C,
+// loopover#6444), so no write path sets it anymore. resolveEffectiveSettings's own downgrade logic
+// (src/signals/focus-manifest.ts) still applies the same rule on the read/resolver path.
// Maintainer BYOK provider key. Write-only: the key is encrypted at rest and never returned. A loose
// prefix check catches the common provider/key mismatch (e.g. pasting an OpenAI key under Anthropic)
@@ -774,20 +752,22 @@ const repositoryLinearKeySchema = z.object({
key: z.string().trim().min(20).max(400),
});
-// Maintainer-settable AI-review config (the non-secret subset of settings). The secret key is set
+// Maintainer-settable AI-review config. mode/byok/provider/model/allAuthors are config-as-code only now
+// (Batch C, loopover#6444) -- set via a repo's own .loopover.yml gate.aiReview.* block, not this route --
+// so they are intentionally NOT accepted here anymore (a caller submitting the old shape gets a clean
+// validation error naming the current route, not a silently-ignored write). The secret key is set
// separately via the ai-key route; never here.
-const repositoryAiReviewSchema = z.object({
- mode: z.enum(["off", "advisory", "block"]),
- byok: z.boolean().default(false),
- provider: z.enum(["anthropic", "openai"]).nullable().optional(),
- model: z.string().trim().min(1).max(120).nullable().optional(),
- allAuthors: z.boolean().default(false),
- closeOwnerAuthors: z.boolean().optional(),
- // Disposition for a sub-aiReviewCloseConfidence-floor ai_consensus_defect/ai_review_split finding (#4603).
- // Optional so a caller that only ever cared about mode/byok/provider/model keeps its historical effect --
- // upsertRepositorySettings applies its own "hold_for_review" default when omitted.
- lowConfidenceDisposition: z.enum(["one_shot", "hold_for_review", "advisory_only"]).optional(),
-});
+const repositoryAiReviewSchema = z
+ .object({
+ closeOwnerAuthors: z.boolean().optional(),
+ // Disposition for a sub-aiReviewCloseConfidence-floor ai_consensus_defect/ai_review_split finding (#4603).
+ // Optional -- upsertRepositorySettings applies its own "hold_for_review" default when omitted.
+ lowConfidenceDisposition: z.enum(["one_shot", "hold_for_review", "advisory_only"]).optional(),
+ })
+ // .strict() so a caller still sending the pre-Batch-C shape (mode/byok/provider/model/allAuthors) gets
+ // an immediate "unrecognized key" validation error naming exactly which fields moved, instead of those
+ // keys being silently dropped and the request appearing to partially succeed.
+ .strict();
const contributorIssueDraftGenerateSchema = z.object({
dryRun: z.boolean().optional().default(true),
@@ -2563,7 +2543,6 @@ export function createApp() {
if (!parsed.success) return c.json({ error: "invalid_repository_settings", issues: parsed.error.issues }, 400);
const current = await getRepositorySettings(c.env, fullName);
const changes = Object.fromEntries(Object.entries(parsed.data).filter(([, value]) => value !== undefined)) as Partial;
- if (changes.qualityGateMode !== undefined) changes.qualityGateMode = downgradeQualityGateMode(changes.qualityGateMode);
const updated = await upsertRepositorySettings(c.env, { ...current, ...changes, repoFullName: fullName });
await recordAuditEvent(c.env, {
eventType: "repo.settings_updated",
@@ -2679,14 +2658,17 @@ export function createApp() {
});
// Maintainer activation demo (#701): a repo-specific "here's what LoopOver would have surfaced" preview
- // over recent PRs, plus a one-click advisory ramp. Maintainer-scoped + per-repo. Deterministic (no AI run).
+ // over recent PRs. Maintainer-scoped + per-repo. Deterministic (no AI run).
app.get("/v1/repos/:owner/:repo/activation-preview", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const gate = await requireRepoMaintainer(c, fullName);
if (gate instanceof Response) return gate;
+ // resolveRepositorySettings (not the raw getRepositorySettings row), so reviewCheckMode/aiReviewMode --
+ // both config-as-code only now (Batch C, loopover#6444) -- reflect a repo's real .loopover.yml-driven
+ // state instead of always reporting the hardcoded DB default (#6444 follow-up to the #6557-class bug).
const [repo, settings, pullRequests] = await Promise.all([
getRepository(c.env, fullName),
- getRepositorySettings(c.env, fullName),
+ resolveRepositorySettings(c.env, fullName),
listPullRequests(c.env, fullName),
]);
return c.json(
@@ -2785,28 +2767,10 @@ export function createApp() {
return c.json({ repoFullName: fullName, cleared: true });
});
- // One-click "enable advisory mode" — turns on the gate + deterministic rules in advisory (non-blocking)
- // mode. Merges onto current settings so unrelated fields are preserved.
- app.post("/v1/repos/:owner/:repo/activation", async (c) => {
- const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
- const gate = await requireRepoWriteAccess(c, fullName);
- if (gate instanceof Response) return gate;
- const current = await getRepositorySettings(c.env, fullName);
- const updated = await upsertRepositorySettings(c.env, { ...current, ...recommendedAdvisoryActivationSettings() });
- // checkRunMode dropped (Batch A, loopover#6442): recommendedAdvisoryActivationSettings() no longer sets
- // it (writing it is now a no-op), so echoing updated.checkRunMode here would just always report the
- // hardcoded default regardless of what this activation actually did.
- return c.json({
- repoFullName: fullName,
- reviewCheckMode: updated.reviewCheckMode,
- linkedIssueGateMode: updated.linkedIssueGateMode,
- duplicatePrGateMode: updated.duplicatePrGateMode,
- qualityGateMode: updated.qualityGateMode,
- });
- });
-
- // Maintainer self-serve AI-review config (non-secret: mode/byok/provider/model). Session-authenticated +
- // scoped to repos the maintainer has live GitHub write access to. The secret provider key goes through the ai-key route.
+ // Maintainer self-serve AI-review config. mode/byok/provider/model/allAuthors are config-as-code only now
+ // (Batch C, loopover#6444) -- set via a repo's own .loopover.yml gate.aiReview.* block; this route can
+ // only still persist closeOwnerAuthors/lowConfidenceDisposition. Session-authenticated + scoped to repos
+ // the maintainer has live GitHub write access to. The secret provider key goes through the ai-key route.
// Merges onto current settings so unrelated settings are preserved.
app.put("/v1/repos/:owner/:repo/ai-review", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
@@ -2817,26 +2781,28 @@ export function createApp() {
const current = await getRepositorySettings(c.env, fullName);
const updated = await upsertRepositorySettings(c.env, {
...current,
- aiReviewMode: parsed.data.mode,
- aiReviewByok: parsed.data.byok,
- aiReviewProvider: parsed.data.provider,
- aiReviewModel: parsed.data.model,
- aiReviewAllAuthors: parsed.data.allAuthors,
aiReviewLowConfidenceDisposition: parsed.data.lowConfidenceDisposition ?? current.aiReviewLowConfidenceDisposition,
closeOwnerAuthors: parsed.data.closeOwnerAuthors ?? current.closeOwnerAuthors,
});
- // getRepositorySettings normalizes these to a concrete value or null (never undefined).
+ // mode/byok/provider/model/allAuthors read from the manifest-resolved settings (not `updated`, which is
+ // always the hardcoded default for these five now) so the response reflects a repo's real
+ // .loopover.yml-driven state instead of silently reporting the same constant on every save.
+ const manifest = await loadRepoFocusManifest(c.env, fullName);
+ const resolved = resolveEffectiveSettings(updated, manifest);
return c.json({
- aiReviewMode: updated.aiReviewMode,
- aiReviewByok: updated.aiReviewByok,
- aiReviewProvider: updated.aiReviewProvider ?? null,
- aiReviewModel: updated.aiReviewModel ?? null,
- aiReviewAllAuthors: updated.aiReviewAllAuthors,
+ aiReviewMode: resolved.aiReviewMode,
+ aiReviewByok: resolved.aiReviewByok,
+ aiReviewProvider: resolved.aiReviewProvider ?? null,
+ aiReviewModel: resolved.aiReviewModel ?? null,
+ aiReviewAllAuthors: resolved.aiReviewAllAuthors,
// parseAiReviewLowConfidenceDisposition's return type is non-nullable and already falls back to the
// literal "hold_for_review" itself, so this side of the `??` can never actually run.
/* v8 ignore next */
aiReviewLowConfidenceDisposition: updated.aiReviewLowConfidenceDisposition ?? "hold_for_review",
closeOwnerAuthors: updated.closeOwnerAuthors,
+ // Tells the dashboard these five fields are read-only now and where to configure them instead --
+ // see apps/loopover-ui's AiReviewSettings component, which stops rendering them as editable inputs.
+ aiReviewConfigAsCode: true,
});
});
@@ -4271,17 +4237,7 @@ export function createApp() {
return c.json(
await upsertRepositorySettings(c.env, {
repoFullName: fullName,
- reviewCheckMode: parsed.data.reviewCheckMode,
gatePack: parsed.data.gatePack,
- linkedIssueGateMode: parsed.data.linkedIssueGateMode,
- duplicatePrGateMode: parsed.data.duplicatePrGateMode,
- qualityGateMode: downgradeQualityGateMode(parsed.data.qualityGateMode),
- qualityGateMinScore: parsed.data.qualityGateMinScore,
- aiReviewMode: parsed.data.aiReviewMode,
- aiReviewByok: parsed.data.aiReviewByok,
- aiReviewProvider: parsed.data.aiReviewProvider,
- aiReviewModel: parsed.data.aiReviewModel,
- aiReviewAllAuthors: parsed.data.aiReviewAllAuthors,
aiReviewLowConfidenceDisposition: parsed.data.aiReviewLowConfidenceDisposition,
closeOwnerAuthors: parsed.data.closeOwnerAuthors,
autoLabelEnabled: parsed.data.autoLabelEnabled,
@@ -5145,6 +5101,12 @@ const CONFIG_AS_CODE_ONLY_FIELDS = [
"reviewEvasionLabel",
"reviewEvasionComment",
"mergeTrainMode",
+ // Batch C (loopover#6444): only reviewCheckMode is read directly in this file (buildGithubAppBehavior) --
+ // the other 10 Batch C fields (linkedIssueGateMode, duplicatePrGateMode, qualityGateMode,
+ // qualityGateMinScore, selfAuthoredLinkedIssueGateMode, aiReviewMode, aiReviewByok, aiReviewProvider,
+ // aiReviewModel, aiReviewAllAuthors) are never read by registration-readiness.ts/this response, so they
+ // don't need adding here.
+ "reviewCheckMode",
] as const satisfies ReadonlyArray;
function applyConfigAsCodeOnlyFields(rawSettings: RepositorySettings, resolvedSettings: RepositorySettings): RepositorySettings {
const settings = { ...rawSettings };
diff --git a/src/db/repositories.ts b/src/db/repositories.ts
index 384ad49ed0..0a822a7223 100644
--- a/src/db/repositories.ts
+++ b/src/db/repositories.ts
@@ -661,27 +661,30 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
checkRunMode: "off",
checkRunDetailLevel: "minimal",
regateSweepOrderMode: "staleness",
- reviewCheckMode: parseReviewCheckMode(row.reviewCheckMode),
+ // Config-as-code only (Batch C, loopover#6444): no DB column backs these 11 fields anymore -- the
+ // built-in default here is unconditional (not row-dependent), matching the !row branch above.
+ // resolveEffectiveSettings still overlays a repo's .loopover.yml settings./gate.* value over this default.
+ reviewCheckMode: "disabled",
// Config-as-code only (loopover#6445): see the comment on the autoMaintain block below.
autoProjectMilestoneMatch: "off",
autoProjectMilestoneMatchBackend: "github",
gatePack: parseGatePack(row.gatePack),
- linkedIssueGateMode: parseGateRuleMode(row.linkedIssueGateMode),
- duplicatePrGateMode: parseGateRuleMode(row.duplicatePrGateMode),
- qualityGateMode: parseGateRuleMode(row.qualityGateMode),
- qualityGateMinScore: normalizeQualityGateMinScore(row.qualityGateMinScore),
+ linkedIssueGateMode: "advisory",
+ duplicatePrGateMode: "block",
+ qualityGateMode: "advisory",
+ qualityGateMinScore: null,
slopGateMode: parseGateRuleMode(row.slopGateMode),
mergeReadinessGateMode: parseGateRuleMode(row.mergeReadinessGateMode),
manifestPolicyGateMode: parseGateRuleMode(row.manifestPolicyGateMode),
- selfAuthoredLinkedIssueGateMode: parseGateRuleMode(row.selfAuthoredLinkedIssueGateMode),
+ selfAuthoredLinkedIssueGateMode: "advisory",
linkedIssueSatisfactionGateMode: parseGateRuleMode(row.linkedIssueSatisfactionGateMode),
slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore),
slopAiAdvisory: row.slopAiAdvisory,
- aiReviewMode: parseGateRuleMode(row.aiReviewMode),
- aiReviewByok: row.aiReviewByok,
- aiReviewProvider: normalizeAiReviewProvider(row.aiReviewProvider),
- aiReviewModel: row.aiReviewModel ?? null,
- aiReviewAllAuthors: row.aiReviewAllAuthors,
+ aiReviewMode: "off",
+ aiReviewByok: false,
+ aiReviewProvider: null,
+ aiReviewModel: null,
+ aiReviewAllAuthors: false,
aiReviewLowConfidenceDisposition: parseAiReviewLowConfidenceDisposition(row.aiReviewLowConfidenceDisposition),
closeOwnerAuthors: row.closeOwnerAuthors,
autoLabelEnabled: row.autoLabelEnabled,
@@ -793,27 +796,31 @@ export async function upsertRepositorySettings(env: Env, settings: Partial {
- // checkRunMode moved off the DB entirely (Batch A, loopover#6442) -- writing it via upsertRepositorySettings
- // is now a silent no-op, so it's dropped from this one-click patch rather than pretending to activate it.
- // Turning check-run mode on now requires a repo's own .loopover.yml settings.checkRunMode -- there is no
- // config-as-code write mechanism this one-click action can use to set that on the maintainer's behalf.
- return {
- reviewCheckMode: "required",
- linkedIssueGateMode: "advisory",
- duplicatePrGateMode: "advisory",
- qualityGateMode: "advisory",
- };
-}
+// The one-click "enable advisory mode" patch (recommendedAdvisoryActivationSettings) was removed here:
+// reviewCheckMode, linkedIssueGateMode, duplicatePrGateMode, and qualityGateMode are ALL config-as-code
+// only now (Batch C, loopover#6444) -- writing any of them via upsertRepositorySettings is a silent
+// no-op, so there was nothing left for a one-click DB-write action to meaningfully do. Enabling the gate
+// now requires a repo's own .loopover.yml gate.checkMode (or the legacy settings.reviewCheckMode alias).
diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts
index ad4a1b30a6..3a3612d166 100644
--- a/test/integration/api.test.ts
+++ b/test/integration/api.test.ts
@@ -430,9 +430,10 @@ describe("api routes", () => {
await expect(response.json()).resolves.toMatchObject({ repoFullName: "acme/badged", badgeEnabled: false });
});
- it("downgrades qualityGateMode: block to advisory through the internal settings write endpoint too (#2267)", async () => {
- // Readiness/quality can never hard-block a PR — the internal full-settings write path (used by tooling,
- // not just the maintainer dashboard) gets the identical downgrade so it can't persist "block" either.
+ it("ignores a qualityGateMode opt-in posted to the internal settings write endpoint (config-as-code only now, #6444)", async () => {
+ // qualityGateMode was removed from repositorySettingsSchema entirely in Batch C (loopover#6444) --
+ // it's config-as-code only via .loopover.yml's gate.readiness.mode now, so upsertRepositorySettings
+ // always returns its hardcoded "advisory" default regardless of what a caller posts here.
const app = createApp();
const env = createTestEnv();
const response = await app.request(
@@ -444,10 +445,11 @@ describe("api routes", () => {
await expect(response.json()).resolves.toMatchObject({ repoFullName: "acme/readiness-block", qualityGateMode: "advisory" });
});
- it("ignores an unknown gateCheckMode key in the request body through the internal settings write endpoint (#4618/#5373)", async () => {
- // gateCheckMode was removed entirely from RepositorySettings (#5373); the internal full-replace route's
- // schema never accepted it as input even before removal (#4618). A caller sending it gets the schema's
- // plain reviewCheckMode default ("disabled") -- the unknown key is silently ignored, not rejected.
+ it("ignores reviewCheckMode/gateCheckMode keys in the request body through the internal settings write endpoint (#4618/#5373/#6444)", async () => {
+ // gateCheckMode was removed entirely from RepositorySettings (#5373); reviewCheckMode itself was
+ // subsequently removed from repositorySettingsSchema too (Batch C, loopover#6444) -- it's
+ // config-as-code only via .loopover.yml's gate.checkMode now, so both keys are silently ignored and
+ // the response always reflects the hardcoded "disabled" default.
const app = createApp();
const env = createTestEnv();
const enabled = await app.request(
@@ -458,14 +460,13 @@ describe("api routes", () => {
expect(enabled.status).toBe(200);
await expect(enabled.json()).resolves.toMatchObject({ reviewCheckMode: "disabled" });
- // reviewCheckMode set directly is the real, honored write path.
const explicit = await app.request(
"/v1/internal/repos/acme/legacy-gate-explicit/settings",
{ method: "POST", headers: { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}`, "content-type": "application/json" }, body: JSON.stringify({ gateCheckMode: "off", reviewCheckMode: "visible" }) },
env,
);
expect(explicit.status).toBe(200);
- await expect(explicit.json()).resolves.toMatchObject({ reviewCheckMode: "visible" });
+ await expect(explicit.json()).resolves.toMatchObject({ reviewCheckMode: "disabled" });
});
it("rejects invalid public GitHub repo stats paths before calling GitHub", async () => {
@@ -2677,11 +2678,11 @@ describe("api routes", () => {
method: "PUT",
headers: ownerHeaders,
// #773/#774/#776: the agent-layer config is settable here; the DB layer drops an unknown action class.
- // #2267: qualityGateMode: "block" is downgraded to "advisory" on write — readiness/quality can never
- // hard-block a PR, so the dashboard/API save path can't persist a value implying enforcement it doesn't
- // have. slopGateMode: "block" is a DIFFERENT, legitimately-blockable dimension and is left untouched.
- // #4618/#5373: gateCheckMode is an unknown key with no effect here (removed from RepositorySettings
- // entirely) -- included to confirm it is silently ignored, not to drive reviewCheckMode.
+ // slopGateMode: "block" is a legitimately-blockable dimension, unaffected by Batch C.
+ // #4618/#5373/#6444: gateCheckMode/reviewCheckMode/qualityGateMode are unknown keys here now --
+ // reviewCheckMode/qualityGateMode were removed from maintainerSettingsSchema entirely in Batch C
+ // (loopover#6444), config-as-code only via .loopover.yml now -- included to confirm they are
+ // silently ignored (ignored keys on a non-strict `.partial()` schema), not rejected.
// mergeTrainMode moved off the DB entirely (Batch B, loopover#6443) -- no longer a writable key on this
// route, config-as-code only via .loopover.yml now. Same for autoMaintain (loopover#6445).
body: JSON.stringify({ gateCheckMode: "enabled", reviewCheckMode: "required", slopGateMode: "block", slopGateMinScore: 55, qualityGateMode: "block", autonomy: { merge: "auto_with_approval", deploy: "auto" }, agentPaused: true, agentDryRun: true }),
@@ -2690,28 +2691,22 @@ describe("api routes", () => {
);
expect(settingsUpdate.status).toBe(200);
await expect(settingsUpdate.json()).resolves.toMatchObject({
- reviewCheckMode: "required",
+ reviewCheckMode: "disabled", // config-as-code only (#6444) -- always the hardcoded default now
slopGateMode: "block",
slopGateMinScore: 55,
- qualityGateMode: "advisory", // #2267: downgraded, not persisted as "block"
+ qualityGateMode: "advisory", // config-as-code only (#6444) -- always the hardcoded default now
autonomy: { merge: "auto_with_approval" }, // unknown action class dropped by the DB normalizer
agentPaused: true, // #776 kill-switch
agentDryRun: true,
});
- // #4618/#5373: gateCheckMode alone has NO effect -- it is an unknown key, so reviewCheckMode stays
- // whatever it already was (still "required" from the write immediately above), not derived "disabled".
- const settingsUpdateOff = await app.request(
- "/v1/repos/repo-owner/owned-repo/settings",
- { method: "PUT", headers: ownerHeaders, body: JSON.stringify({ gateCheckMode: "off" }) },
- ownerEnv,
- );
- expect(settingsUpdateOff.status).toBe(200);
- await expect(settingsUpdateOff.json()).resolves.toMatchObject({ reviewCheckMode: "required" });
- // autoMaintain moved off the DB entirely (config-as-code, loopover#6445) -- no longer a writable key on
- // this route, so it's no longer validated at this API boundary either.
+ // gateCheckMode/reviewCheckMode/autoMaintain are all unknown/removed keys now (Batch C loopover#6444 +
+ // Batch D loopover#6445 both moved fields off this route to config-as-code-only) -- reviewCheckMode is
+ // covered by the dedicated "ignores reviewCheckMode/gateCheckMode keys..." test above, and autoMaintain
+ // has no writable shape left to bounds-check, so both of those follow-up probes are gone. Probe a
+ // still-real gate field instead for the invalid-value 400 case.
const settingsInvalid = await app.request(
"/v1/repos/repo-owner/owned-repo/settings",
- { method: "PUT", headers: ownerHeaders, body: JSON.stringify({ reviewCheckMode: "nonsense" }) },
+ { method: "PUT", headers: ownerHeaders, body: JSON.stringify({ slopGateMode: "nonsense" }) },
ownerEnv,
);
expect(settingsInvalid.status).toBe(400);
@@ -4667,7 +4662,8 @@ describe("api routes", () => {
expect(queuedBurden.status).toBe(202);
const queuedSignals = await app.request("/v1/internal/jobs/generate-signal-snapshots", { method: "POST", headers: internalHeaders, body: JSON.stringify({ repoFullName: "owner/repo" }) }, env);
expect(queuedSignals.status).toBe(202);
- expect((await app.request("/v1/internal/repos/owner/repo/settings", { method: "POST", headers: internalHeaders, body: JSON.stringify({ reviewCheckMode: "bad" }) }, env)).status).toBe(400);
+ // reviewCheckMode is gone from repositorySettingsSchema entirely (#6444) -- probe a still-real field instead.
+ expect((await app.request("/v1/internal/repos/owner/repo/settings", { method: "POST", headers: internalHeaders, body: JSON.stringify({ gatePack: "bad" }) }, env)).status).toBe(400);
});
it("settings-preview never mutates GitHub state", async () => {
@@ -6506,9 +6502,10 @@ describe("api routes", () => {
env,
);
expect(signalsOne.status).toBe(202);
+ // reviewCheckMode is gone from repositorySettingsSchema entirely (#6444) -- probe a still-real field instead.
const invalidSettings = await app.request(
"/v1/internal/repos/owner/repo/settings",
- { method: "POST", headers: { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}` }, body: JSON.stringify({ reviewCheckMode: "loud" }) },
+ { method: "POST", headers: { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}` }, body: JSON.stringify({ gatePack: "loud" }) },
env,
);
expect(invalidSettings.status).toBe(400);
diff --git a/test/integration/maintainer-activation.test.ts b/test/integration/maintainer-activation.test.ts
index 1d9adbfa8e..0e0aa43e0e 100644
--- a/test/integration/maintainer-activation.test.ts
+++ b/test/integration/maintainer-activation.test.ts
@@ -13,7 +13,7 @@ const mockedPermission = vi.mocked(getRepositoryCollaboratorPermission);
const FULL_NAME = "owner/repo";
const PATH_PREVIEW = "/v1/repos/owner/repo/activation-preview";
-const PATH_ACTIVATE = "/v1/repos/owner/repo/activation";
+const PATH_SETTINGS = "/v1/repos/owner/repo/settings";
async function seedRepo(env: Env, owner: string, name: string, installationId: number): Promise {
await upsertInstallation(env, {
@@ -33,7 +33,7 @@ function stubMinerFetch() {
describe("maintainer activation routes", () => {
afterEach(() => vi.unstubAllGlobals());
beforeEach(() => mockedPermission.mockReset());
- it("lets a maintainer preview activation and flip on advisory mode in one action", async () => {
+ it("lets a maintainer preview activation (reviewCheckMode is config-as-code only now, #6444)", async () => {
const app = createApp();
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "operator-admin" });
const { token } = await createSessionForGitHubUser(env, { login: "operator-admin", id: 1 });
@@ -44,63 +44,12 @@ describe("maintainer activation routes", () => {
const previewBody = (await preview.json()) as { repoFullName: string; recommendedAction: string | null; currentReviewCheckMode: string; evaluatedCount: number };
expect(previewBody).toMatchObject({ repoFullName: FULL_NAME, recommendedAction: "enable_advisory", currentReviewCheckMode: "disabled", evaluatedCount: 0 });
- const activate = await app.request(PATH_ACTIVATE, { method: "POST", headers, body: "{}" }, env);
- expect(activate.status).toBe(200);
- expect(await activate.json()).toMatchObject({
- repoFullName: FULL_NAME,
- reviewCheckMode: "required",
- linkedIssueGateMode: "advisory",
- duplicatePrGateMode: "advisory",
- qualityGateMode: "advisory",
- });
-
- // The flip persisted, and the preview now reports nothing left to enable.
- expect((await getRepositorySettings(env, FULL_NAME)).reviewCheckMode).toBe("required");
- const afterPreview = await app.request(PATH_PREVIEW, { headers }, env);
- expect((await afterPreview.json() as { recommendedAction: string | null }).recommendedAction).toBeNull();
- });
-
-
- it("forbids read-only repo collaborators from activating advisory checks", async () => {
- const app = createApp();
- const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });
- await seedRepo(env, "owner", "repo", 201);
- await upsertPullRequestFromGitHub(env, FULL_NAME, {
- number: 7,
- title: "docs tweak",
- state: "open",
- user: { login: "reader" },
- author_association: "COLLABORATOR",
- head: { sha: "abc123", ref: "docs" },
- base: { ref: "main" },
- labels: [],
- });
- stubMinerFetch();
- mockedPermission.mockResolvedValue("read");
- const { token } = await createSessionForGitHubUser(env, { login: "reader", id: 777 });
- const headers = { cookie: `loopover_session=${token}`, "content-type": "application/json" };
-
- const preview = await app.request(PATH_PREVIEW, { headers }, env);
- expect(preview.status).toBe(200);
-
- const activate = await app.request(PATH_ACTIVATE, { method: "POST", headers, body: "{}" }, env);
- expect(activate.status).toBe(403);
- expect(await activate.json()).toMatchObject({ error: "insufficient_repo_permission" });
+ // POST /activation (the one-click "enable advisory mode" action) was removed here: reviewCheckMode/
+ // linkedIssueGateMode/duplicatePrGateMode/qualityGateMode are all config-as-code only now (Batch C,
+ // loopover#6444) -- there was nothing left for a DB-write action to meaningfully do.
expect((await getRepositorySettings(env, FULL_NAME)).reviewCheckMode).toBe("disabled");
});
- it("allows a session with GitHub write permission to activate advisory checks", async () => {
- const app = createApp();
- const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });
- await seedRepo(env, "owner", "repo", 201);
- stubMinerFetch();
- mockedPermission.mockResolvedValue("write");
- const { token } = await createSessionForGitHubUser(env, { login: "owner", id: 201 });
- const response = await app.request(PATH_ACTIVATE, { method: "POST", headers: { cookie: `loopover_session=${token}`, "content-type": "application/json" }, body: "{}" }, env);
- expect(response.status).toBe(200);
- expect(await response.json()).toMatchObject({ repoFullName: FULL_NAME, reviewCheckMode: "required" });
- });
-
it("forbids read-only repo collaborators from writing agent settings", async () => {
const app = createApp();
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });
@@ -120,7 +69,7 @@ describe("maintainer activation routes", () => {
const { token } = await createSessionForGitHubUser(env, { login: "reader", id: 777 });
const headers = { cookie: `loopover_session=${token}`, "content-type": "application/json" };
- const update = await app.request(`${PATH_ACTIVATE.replace("/activation", "/settings")}`, {
+ const update = await app.request(PATH_SETTINGS, {
method: "PUT",
headers,
body: JSON.stringify({ autonomy: { merge: "auto" }, autoMaintain: { requireApprovals: 0, mergeMethod: "merge" } }),
@@ -140,7 +89,7 @@ describe("maintainer activation routes", () => {
stubMinerFetch();
mockedPermission.mockResolvedValue("write");
const { token } = await createSessionForGitHubUser(env, { login: "owner", id: 201 });
- const response = await app.request(`${PATH_ACTIVATE.replace("/activation", "/settings")}`, {
+ const response = await app.request(PATH_SETTINGS, {
method: "PUT",
headers: { cookie: `loopover_session=${token}`, "content-type": "application/json" },
// autoMaintain moved off the DB entirely (config-as-code, loopover#6445) -- no longer a writable key on
@@ -152,26 +101,27 @@ describe("maintainer activation routes", () => {
expect(await response.json()).toMatchObject({ autonomy: { merge: "auto_with_approval" } });
});
- it("persists selfAuthoredLinkedIssueGateMode from the settings PUT (API/OpenAPI parity)", async () => {
- // The dashboard save path (maintainerSettingsSchema) omitted this DB-backed gate mode, so a maintainer
- // setting it to `block` via the API had it silently stripped by the validator — while its OpenAPI schema
- // and config-as-code path both accept it, and the gate genuinely enforces `block`. Prove the round-trip.
+ it("ignores selfAuthoredLinkedIssueGateMode on the settings PUT -- config-as-code only now (#6444)", async () => {
+ // selfAuthoredLinkedIssueGateMode was removed from maintainerSettingsSchema in Batch C
+ // (loopover#6444): it's config-as-code only via .loopover.yml's gate.selfAuthoredLinkedIssue block
+ // now, so a dashboard save attempting to set it is silently dropped (unknown key on a non-strict
+ // partial schema), not persisted, and the response reflects the hardcoded "advisory" default.
const app = createApp();
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" });
await seedRepo(env, "owner", "repo", 202);
stubMinerFetch();
mockedPermission.mockResolvedValue("write");
const { token } = await createSessionForGitHubUser(env, { login: "owner", id: 202 });
- const response = await app.request(`${PATH_ACTIVATE.replace("/activation", "/settings")}`, {
+ const response = await app.request(PATH_SETTINGS, {
method: "PUT",
headers: { cookie: `loopover_session=${token}`, "content-type": "application/json" },
body: JSON.stringify({ selfAuthoredLinkedIssueGateMode: "block" }),
}, env);
expect(response.status).toBe(200);
- expect(await response.json()).toMatchObject({ selfAuthoredLinkedIssueGateMode: "block" });
+ expect(await response.json()).toMatchObject({ selfAuthoredLinkedIssueGateMode: "advisory" });
const persisted = await getRepositorySettings(env, FULL_NAME);
- expect(persisted.selfAuthoredLinkedIssueGateMode).toBe("block");
+ expect(persisted.selfAuthoredLinkedIssueGateMode).toBe("advisory");
});
it("forbids a non-maintainer session from the activation preview", async () => {
diff --git a/test/integration/routes-errors.test.ts b/test/integration/routes-errors.test.ts
index d77914249b..b9242d1363 100644
--- a/test/integration/routes-errors.test.ts
+++ b/test/integration/routes-errors.test.ts
@@ -1103,12 +1103,13 @@ describe("api route guards and error branches", () => {
).status,
).toBe(200);
+ // reviewCheckMode is gone from repositorySettingsSchema entirely (#6444) -- probe a still-real field instead.
expect(
(
await app.request("/v1/internal/repos/JSONbored/gittensory/settings", {
method: "POST",
headers: internalHeaders(env),
- body: JSON.stringify({ reviewCheckMode: "bad" }),
+ body: JSON.stringify({ gatePack: "bad" }),
}, env)
).status,
).toBe(400);
diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts
index 7b856b46c1..e764f4f7ac 100644
--- a/test/unit/data-spine.test.ts
+++ b/test/unit/data-spine.test.ts
@@ -274,9 +274,11 @@ describe("data spine repositories", () => {
expect((await getRepositorySettings(env, "owner/repo")).gatePack).toBe("oss-anti-slop");
// Left DB-based (not manifest injection): this exercises getRepositorySettings's own raw DB round-trip
// directly -- it has no manifest overlay (that's resolveRepositorySettings/resolveEffectiveSettings) --
- // so a manifest-only write here would never be observed by the assertion below.
- await upsertRepositorySettings(env, { repoFullName: "owner/repo", gatePack: "gittensor", linkedIssueGateMode: "block" });
- expect(await getRepositorySettings(env, "owner/repo")).toMatchObject({ gatePack: "gittensor", linkedIssueGateMode: "block" });
+ // so a manifest-only write here would never be observed by the assertion below. linkedIssueGateMode is
+ // config-as-code only now (Batch C, loopover#6444), so mergeReadinessGateMode probes the raw DB round-trip
+ // instead.
+ await upsertRepositorySettings(env, { repoFullName: "owner/repo", gatePack: "gittensor", mergeReadinessGateMode: "block" });
+ expect(await getRepositorySettings(env, "owner/repo")).toMatchObject({ gatePack: "gittensor", mergeReadinessGateMode: "block" });
await upsertRepositorySettings(env, { repoFullName: "owner/defaultpack" });
expect((await getRepositorySettings(env, "owner/defaultpack")).gatePack).toBe("gittensor");
// slop gate (#530/#532) round-trips and defaults to off.
diff --git a/test/unit/maintainer-activation.test.ts b/test/unit/maintainer-activation.test.ts
index 7e55ed6642..8fce9e7c21 100644
--- a/test/unit/maintainer-activation.test.ts
+++ b/test/unit/maintainer-activation.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { buildMaintainerActivationPreview, recommendedAdvisoryActivationSettings } from "../../src/services/maintainer-activation";
+import { buildMaintainerActivationPreview } from "../../src/services/maintainer-activation";
import type { PullRequestRecord, RepositoryRecord, RepositorySettings } from "../../src/types";
const repo: RepositoryRecord = {
@@ -233,14 +233,3 @@ describe("buildMaintainerActivationPreview", () => {
expect(open.findings.map((finding) => finding.code)).not.toContain("duplicate_pr_risk");
});
});
-
-describe("recommendedAdvisoryActivationSettings", () => {
- it("enables the gate + deterministic rules in advisory (non-blocking) mode", () => {
- expect(recommendedAdvisoryActivationSettings()).toEqual({
- reviewCheckMode: "required",
- linkedIssueGateMode: "advisory",
- duplicatePrGateMode: "advisory",
- qualityGateMode: "advisory",
- });
- });
-});
diff --git a/test/unit/queue-4.test.ts b/test/unit/queue-4.test.ts
index fb7a67e23a..2bbbff7627 100644
--- a/test/unit/queue-4.test.ts
+++ b/test/unit/queue-4.test.ts
@@ -4053,11 +4053,6 @@ describe("queue processors", () => {
await upsertRepositorySettings(env, {
repoFullName: "JSONbored/gittensory",
autoLabelEnabled: false,
- // Batch-C: reviewCheckMode/aiReviewMode stay DB-backed here (not moved to upsertRepoFocusManifest) --
- // this test exercises the real .loopover.yml raw-fetch path below, and upsertRepoFocusManifest would
- // poison the 6h manifest cache and skip that fetch entirely.
- reviewCheckMode: "required",
- aiReviewMode: "block",
gatePack: "oss-anti-slop",
});
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -4073,8 +4068,10 @@ describe("queue processors", () => {
if (url.includes("/issues/7/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 });
if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } });
// The repo's own review.tone opt-in (#2044) -- a maintainer voice brief, distinct from review.instructions.
+ // reviewCheckMode/aiReviewMode are config-as-code only now (Batch C, loopover#6444) -- set via gate.checkMode/
+ // gate.aiReview.mode here instead of upsertRepositorySettings above.
if (url === "https://raw.githubusercontent.com/JSONbored/gittensory/HEAD/.loopover.yml") {
- return new Response("settings:\n commentMode: all_prs\n publicSurface: comment_only\n checkRunMode: \"off\"\nreview:\n tone: Keep findings terse and skip pleasantries\n");
+ return new Response("settings:\n commentMode: all_prs\n publicSurface: comment_only\n checkRunMode: \"off\"\ngate:\n checkMode: required\n aiReview:\n mode: block\nreview:\n tone: Keep findings terse and skip pleasantries\n");
}
// Real GitHub raw-content 404s for every other manifest candidate -- without this, Response.json({}) below would 200 the first candidate
// tried and mask the review.tone config crafted above.
@@ -4134,17 +4131,6 @@ describe("queue processors", () => {
await upsertRepositorySettings(env, {
repoFullName: "JSONbored/gittensory",
autoLabelEnabled: false,
- // Batch-C: reviewCheckMode also stays DB-backed here (not moved to upsertRepoFocusManifest) -- this
- // test exercises the real .loopover.yml raw-fetch path below, and upsertRepoFocusManifest would
- // poison the 6h manifest cache and skip that fetch entirely.
- reviewCheckMode: "required",
- // advisory (NOT block): block mode always reviews the full diff, ignoring exclude_paths/path_filters, so
- // only advisory mode exercises the filterReviewFilesForAi branch (src/queue/processors.ts).
- aiReviewMode: "advisory",
- // The PR author below is an unconfirmed contributor; aiReviewAllAuthors is the documented per-repo opt-in
- // that widens the AI-spend gate to every author (already unit-tested in ai-review-advisory.test.ts) so this
- // test doesn't also have to stand up the full miner-confirmation registry mocks just to reach the AI call.
- aiReviewAllAuthors: true,
});
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
@@ -4168,8 +4154,12 @@ describe("queue processors", () => {
// Batch-A fields moved off upsertRepositorySettings above and into this fetched .loopover.yml's
// `settings:` block (config-as-code migration, #6442) so the real yaml-fetch path -- not
// upsertRepoFocusManifest, which would poison the 6h manifest cache and skip this fetch -- still
- // supplies them alongside review.exclude_paths.
- return new Response('settings:\n commentMode: all_prs\n publicSurface: comment_only\n checkRunMode: "off"\nreview:\n exclude_paths:\n - "**/*.generated.ts"\n');
+ // supplies them alongside review.exclude_paths. reviewCheckMode/aiReviewMode/aiReviewAllAuthors are
+ // config-as-code only now too (Batch C, loopover#6444) -- gate.aiReview.mode is "advisory" (NOT
+ // "block": block mode always reviews the full diff, ignoring exclude_paths/path_filters, so only
+ // advisory mode exercises the filterReviewFilesForAi branch). gate.aiReview.allAuthors widens the
+ // AI-spend gate to the unconfirmed contributor author below.
+ return new Response('settings:\n commentMode: all_prs\n publicSurface: comment_only\n checkRunMode: "off"\ngate:\n checkMode: required\n aiReview:\n mode: advisory\n allAuthors: true\nreview:\n exclude_paths:\n - "**/*.generated.ts"\n');
}
// Real GitHub raw-content 404s for every other manifest candidate -- without this, the generic Response.json({}) catch-all below would
// otherwise 200 the FIRST candidate tried and mask the exclude_paths config crafted above.
@@ -4213,12 +4203,7 @@ describe("queue processors", () => {
await upsertRepositorySettings(env, {
repoFullName: "JSONbored/gittensory",
autoLabelEnabled: false,
- // Batch-C: reviewCheckMode/linkedIssueGateMode stay DB-backed here (not moved to upsertRepoFocusManifest) --
- // this test exercises the real .loopover.yml raw-fetch path below, and upsertRepoFocusManifest would
- // poison the 6h manifest cache and skip that fetch entirely.
- reviewCheckMode: "required",
autonomy: { update_branch: "auto" },
- linkedIssueGateMode: "block",
});
let postedBody = "";
const calls = { comments: 0, gateChecks: 0 };
@@ -4283,7 +4268,9 @@ describe("queue processors", () => {
if (url.endsWith("/users/oktofeesh1")) return Response.json({ login: "oktofeesh1", public_repos: 2, followers: 1 });
if (url.includes("/users/oktofeesh1/repos")) return Response.json([{ language: "TypeScript" }]);
if (url === "https://raw.githubusercontent.com/JSONbored/gittensory/HEAD/.loopover.yml") {
- return new Response("settings:\n commentMode: detected_contributors_only\n publicAudienceMode: gittensor_only\n publicSignalLevel: standard\n publicSurface: comment_and_label\n checkRunMode: \"off\"\n checkRunDetailLevel: minimal\n backfillEnabled: true\nreview:\n max_findings:\n blockers: 0\n");
+ // reviewCheckMode/linkedIssueGateMode are config-as-code only now (Batch C, loopover#6444) -- set via
+ // gate.checkMode/gate.linkedIssue here instead of upsertRepositorySettings above.
+ return new Response("settings:\n commentMode: detected_contributors_only\n publicAudienceMode: gittensor_only\n publicSignalLevel: standard\n publicSurface: comment_and_label\n checkRunMode: \"off\"\n checkRunDetailLevel: minimal\n backfillEnabled: true\ngate:\n checkMode: required\n linkedIssue: block\nreview:\n max_findings:\n blockers: 0\n");
}
if (url.includes("/access_tokens")) {
if (gateFinalized && !failedPostGateMint) {
@@ -4706,11 +4693,6 @@ describe("queue processors", () => {
await upsertRepositorySettings(env, {
repoFullName: "JSONbored/gittensory",
autoLabelEnabled: false,
- // Batch-C: reviewCheckMode/aiReviewMode stay DB-backed here (not moved to upsertRepoFocusManifest) --
- // this test exercises the real .loopover.yml raw-fetch path below, and upsertRepoFocusManifest would
- // poison the 6h manifest cache and skip that fetch entirely.
- reviewCheckMode: "required",
- aiReviewMode: "block",
gatePack: "oss-anti-slop",
});
let inlineReviewComments: Array<{ body: string }> = [];
@@ -4719,9 +4701,10 @@ describe("queue processors", () => {
const url = input.toString();
const method = init?.method ?? "GET";
if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
- // .loopover.yml opts into inline comments AND finding categories together.
+ // .loopover.yml opts into inline comments AND finding categories together. reviewCheckMode/aiReviewMode
+ // are config-as-code only now (Batch C, loopover#6444) -- set via gate.checkMode/gate.aiReview.mode.
if (url === "https://raw.githubusercontent.com/JSONbored/gittensory/HEAD/.loopover.yml") {
- return new Response("settings:\n commentMode: all_prs\n publicSurface: comment_only\n checkRunMode: \"off\"\nreview:\n inline_comments: true\n finding_categories: true\n");
+ return new Response("settings:\n commentMode: all_prs\n publicSurface: comment_only\n checkRunMode: \"off\"\ngate:\n checkMode: required\n aiReview:\n mode: block\nreview:\n inline_comments: true\n finding_categories: true\n");
}
// Real GitHub raw-content 404s for every other manifest candidate -- without this, Response.json({}) below would 200 the first candidate
// tried and mask the inline_comments/finding_categories config crafted above.
@@ -4840,10 +4823,7 @@ describe("queue processors", () => {
AI_DAILY_NEURON_BUDGET: "100000",
});
await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123);
- // Batch-C: reviewCheckMode/aiReviewMode stay DB-backed here (not moved to upsertRepoFocusManifest) --
- // this test exercises the real .loopover.yml raw-fetch path below, and upsertRepoFocusManifest would
- // poison the 6h manifest cache and skip that fetch entirely.
- await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autoLabelEnabled: false, reviewCheckMode: "required", aiReviewMode: "block", gatePack: "oss-anti-slop" });
+ await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", autoLabelEnabled: false, gatePack: "oss-anti-slop" });
let unifiedCommentBody = "";
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
@@ -4854,8 +4834,9 @@ describe("queue processors", () => {
// Batch-A fields moved off upsertRepositorySettings above and into this fetched .loopover.yml's
// `settings:` block (config-as-code migration, #6442) so the real yaml-fetch path -- not
// upsertRepoFocusManifest, which would poison the 6h manifest cache and skip this fetch -- still
- // supplies them alongside review.fixHandoff.
- return new Response('settings:\n commentMode: all_prs\n publicSurface: comment_only\n checkRunMode: "off"\nreview:\n inline_comments: true\n fixHandoff: true\n');
+ // supplies them alongside review.fixHandoff. reviewCheckMode/aiReviewMode are config-as-code only
+ // now too (Batch C, loopover#6444), set via gate.checkMode/gate.aiReview.mode here.
+ return new Response('settings:\n commentMode: all_prs\n publicSurface: comment_only\n checkRunMode: "off"\ngate:\n checkMode: required\n aiReview:\n mode: block\nreview:\n inline_comments: true\n fixHandoff: true\n');
}
// Real GitHub raw-content 404s for every other manifest candidate -- without this, Response.json({}) below would 200 the first candidate
// tried and mask the inline_comments/fixHandoff config crafted above.
diff --git a/test/unit/queue-5.test.ts b/test/unit/queue-5.test.ts
index 1fdbe35dc5..1a2356241d 100644
--- a/test/unit/queue-5.test.ts
+++ b/test/unit/queue-5.test.ts
@@ -2885,17 +2885,13 @@ describe("queue processors", () => {
it("overrides the Gate to neutral for THIS commit only when a real write/admin maintainer runs gate-override", async () => {
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123);
- // NOT manifest-converted: this test asserts directly on the raw repository_settings.review_check_mode
- // DB column below (a bypassing raw SQL read, not resolveRepositorySettings/resolveEffectiveSettings),
- // to prove the gate-override doesn't persist a permanent state change -- a manifest override wouldn't
- // be reflected in that raw column read, so reviewCheckMode/linkedIssueGateMode stay DB-injected here.
await upsertRepositorySettings(env, {
repoFullName: "JSONbored/gittensory",
autoLabelEnabled: false,
- reviewCheckMode: "required",
- linkedIssueGateMode: "off",
});
- await upsertRepoFocusManifest(env, "JSONbored/gittensory", { settings: { commentMode: "off", publicSurface: "off", checkRunMode: "off" } });
+ // reviewCheckMode/linkedIssueGateMode are config-as-code only now (Batch C, loopover#6444) -- set via
+ // the manifest, which resolveEffectiveSettings reads (the DB layer would silently ignore them).
+ await upsertRepoFocusManifest(env, "JSONbored/gittensory", { settings: { reviewCheckMode: "required", linkedIssueGateMode: "off", commentMode: "off", publicSurface: "off", checkRunMode: "off" } });
await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", {
number: 90,
title: "Override me",
@@ -2971,10 +2967,10 @@ describe("queue processors", () => {
expect(audit).toMatchObject({ event_type: "github_app.gate_overridden", actor: "maintainer", target_key: "JSONbored/gittensory#90", outcome: "completed" });
const usageEvents = await listProductUsageEvents(env, { limit: 10 });
expect(usageEvents).toEqual(expect.arrayContaining([expect.objectContaining({ surface: "github_app", eventName: "gate_overridden", outcome: "completed" })]));
- // No override state is persisted: the gate stays "required" and the override does NOT persist an advisory,
- // so a follow-up synchronize re-evaluates the Gate from scratch (no permanent bypass).
- const settingsAfter = await env.DB.prepare("select review_check_mode from repository_settings where repo_full_name = ?").bind("JSONbored/gittensory").first<{ review_check_mode: string }>();
- expect(settingsAfter?.review_check_mode).toBe("required");
+ // No override state is persisted: the manifest-resolved gate stays "required" and the override does NOT
+ // persist an advisory, so a follow-up synchronize re-evaluates the Gate from scratch (no permanent bypass).
+ const settingsAfter = await repositorySettingsModule.resolveRepositorySettings(env, "JSONbored/gittensory");
+ expect(settingsAfter.reviewCheckMode).toBe("required");
const overrideAdvisory = await env.DB.prepare("select id from advisories where target_key = ?").bind("JSONbored/gittensory#90").first<{ id: string }>();
expect(overrideAdvisory ?? null).toBeNull();
});
diff --git a/test/unit/repository-settings-linked-issue-defaults.test.ts b/test/unit/repository-settings-linked-issue-defaults.test.ts
index ad9fe373cf..060b2569a5 100644
--- a/test/unit/repository-settings-linked-issue-defaults.test.ts
+++ b/test/unit/repository-settings-linked-issue-defaults.test.ts
@@ -2,10 +2,14 @@ import { describe, expect, it } from "vitest";
import { getRepositorySettings, upsertRepositorySettings } from "../../src/db/repositories";
import { createTestEnv } from "../helpers/d1";
-// #selfhost-linked-issue-gate-drift: repository_settings.linked_issue_gate_mode was persisted as 'block' in
-// production for repos that never explicitly opted into it (migrations/0102_fix_linked_issue_gate_mode_default.sql
-// backfills the historically-drifted rows). These regression tests pin the two paths that must default to
-// 'advisory' going forward: a brand-new row (no DB row yet) and an explicit upsert that omits the field.
+// #selfhost-linked-issue-gate-drift/#6444: repository_settings.linked_issue_gate_mode was persisted as
+// 'block' in production for repos that never explicitly opted into it
+// (migrations/0102_fix_linked_issue_gate_mode_default.sql backfills the historically-drifted rows). The
+// column itself was dropped entirely in Batch C (loopover#6444) -- linkedIssueGateMode is config-as-code
+// only now, so getRepositorySettings/upsertRepositorySettings always return the hardcoded "advisory"
+// default regardless of caller input; resolveEffectiveSettings (not this DB layer) overlays a repo's
+// .loopover.yml gate.linkedIssue value on top. The explicit-opt-in/round-trip tests this file used to
+// carry no longer apply once there is no column left to round-trip through.
describe("repository_settings: linked-issue gate defaults to advisory, not block (#selfhost-linked-issue-gate-drift)", () => {
it("getRepositorySettings returns advisory for a repo with no DB row at all", async () => {
const env = createTestEnv();
@@ -14,26 +18,10 @@ describe("repository_settings: linked-issue gate defaults to advisory, not block
expect(settings.requireLinkedIssue).toBe(false);
});
- it("upsertRepositorySettings persists advisory when the caller omits linkedIssueGateMode entirely", async () => {
+ it("upsertRepositorySettings ignores any caller-supplied linkedIssueGateMode -- the read-back is always the hardcoded default", async () => {
const env = createTestEnv();
- await upsertRepositorySettings(env, { repoFullName: "acme/omits-gate-mode" });
+ await upsertRepositorySettings(env, { repoFullName: "acme/omits-gate-mode", linkedIssueGateMode: "block" });
const settings = await getRepositorySettings(env, "acme/omits-gate-mode");
expect(settings.linkedIssueGateMode).toBe("advisory");
});
-
- it("an explicit block opt-in is persisted and read back as block -- advisory-by-default does not clobber a real opt-in", async () => {
- const env = createTestEnv();
- await upsertRepositorySettings(env, { repoFullName: "acme/explicit-block", linkedIssueGateMode: "block" });
- const settings = await getRepositorySettings(env, "acme/explicit-block");
- expect(settings.linkedIssueGateMode).toBe("block");
- });
-
- it("re-upserting without specifying linkedIssueGateMode keeps the row at its previously-set value (upsert defaults only apply when the field is omitted from the settings object, not merged against the existing row)", async () => {
- const env = createTestEnv();
- await upsertRepositorySettings(env, { repoFullName: "acme/round-trip", linkedIssueGateMode: "block" });
- const settings = await getRepositorySettings(env, "acme/round-trip");
- await upsertRepositorySettings(env, { ...settings, repoFullName: "acme/round-trip" });
- const after = await getRepositorySettings(env, "acme/round-trip");
- expect(after.linkedIssueGateMode).toBe("block");
- });
});
diff --git a/test/unit/repository-settings-review-check-mode.test.ts b/test/unit/repository-settings-review-check-mode.test.ts
index d429b5d9ef..aff1641827 100644
--- a/test/unit/repository-settings-review-check-mode.test.ts
+++ b/test/unit/repository-settings-review-check-mode.test.ts
@@ -2,14 +2,12 @@ import { describe, expect, it } from "vitest";
import { getRepositorySettings, upsertRepositorySettings } from "../../src/db/repositories";
import { createTestEnv } from "../helpers/d1";
-// #2852/#5373: reviewCheckMode is the sole runtime authority for the "Gittensory Orb Review Agent" check-run
-// publish decision (required/visible/disabled). A prior gateCheckMode (off/enabled) field was a deprecated
-// computed read-back of reviewCheckMode with no effect as a write input; it has since been removed from
-// RepositorySettings entirely (#5373) -- passing it to upsertRepositorySettings is now a compile-time error,
-// not just a runtime no-op, so the tests that used to prove "gateCheckMode is ignored as a write input" no
-// longer apply (the type system enforces it more strongly than a runtime assertion ever could). The legacy
-// yml settings.gateCheckMode -> reviewCheckMode dual-write sync still exists one layer up, at
-// packages/loopover-engine/src/focus-manifest.ts's parse step (tracked separately for removal).
+// #2852/#5373/#6444: reviewCheckMode is config-as-code only now (Batch C, loopover#6444) -- the
+// repository_settings.review_check_mode DB column was dropped entirely, so getRepositorySettings/
+// upsertRepositorySettings always return the hardcoded "disabled" default here regardless of what a
+// caller passes in; resolveEffectiveSettings (not this DB layer) overlays a repo's .loopover.yml
+// gate.checkMode value on top. The DB round-trip/invalid-value tests this file used to carry no longer
+// apply once there is no column left to round-trip through.
describe("repository_settings: reviewCheckMode default (#2852)", () => {
it("getRepositorySettings returns disabled for a repo with no DB row at all (conservative, opt-in default)", async () => {
const env = createTestEnv();
@@ -17,30 +15,10 @@ describe("repository_settings: reviewCheckMode default (#2852)", () => {
expect(settings.reviewCheckMode).toBe("disabled");
});
- it("upsertRepositorySettings persists disabled when the caller omits reviewCheckMode entirely", async () => {
+ it("upsertRepositorySettings ignores any caller-supplied reviewCheckMode -- the read-back is always the hardcoded default", async () => {
const env = createTestEnv();
await upsertRepositorySettings(env, { repoFullName: "acme/omits-both" });
const settings = await getRepositorySettings(env, "acme/omits-both");
expect(settings.reviewCheckMode).toBe("disabled");
});
-
- it("an explicit required/visible/disabled opt-in round-trips through a re-upsert that carries it forward explicitly", async () => {
- const env = createTestEnv();
- await upsertRepositorySettings(env, { repoFullName: "acme/round-trip", reviewCheckMode: "visible" });
- const settings = await getRepositorySettings(env, "acme/round-trip");
- expect(settings.reviewCheckMode).toBe("visible");
- // A true read-modify-write caller (the route-handler pattern: spread current settings, then override) must
- // carry the persisted value forward explicitly -- upsertRepositorySettings never merges against the DB row.
- await upsertRepositorySettings(env, { ...settings, repoFullName: "acme/round-trip" });
- const after = await getRepositorySettings(env, "acme/round-trip");
- expect(after.reviewCheckMode).toBe("visible");
- });
-
- it("an invalid persisted DB value fails closed to disabled on read", async () => {
- const env = createTestEnv();
- await upsertRepositorySettings(env, { repoFullName: "acme/malformed" });
- await env.DB.prepare("UPDATE repository_settings SET review_check_mode = ? WHERE repo_full_name = ?").bind("sometimes", "acme/malformed").run();
- const settings = await getRepositorySettings(env, "acme/malformed");
- expect(settings.reviewCheckMode).toBe("disabled");
- });
});
diff --git a/test/unit/routes-ai-byok.test.ts b/test/unit/routes-ai-byok.test.ts
index e172dfbab0..1361783e95 100644
--- a/test/unit/routes-ai-byok.test.ts
+++ b/test/unit/routes-ai-byok.test.ts
@@ -29,33 +29,49 @@ async function seedRepo(env: Env, owner: string, name: string, installationId: n
}
describe("maintainer AI-review config route", () => {
- it("sets mode/byok/provider/model and preserves unrelated settings", async () => {
+ // mode/byok/provider/model/allAuthors are config-as-code only now (Batch C, loopover#6444) --
+ // repositoryAiReviewSchema is .strict(), so a caller still sending the pre-Batch-C shape gets a 400
+ // naming the unrecognized keys instead of a silently-partial success.
+ it("rejects the pre-Batch-C shape (mode/byok/provider/model/allAuthors) as unrecognized keys", async () => {
const app = createApp();
const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET });
- await upsertRepositorySettings(env, { repoFullName: REPO, reviewCheckMode: "required", autoLabelEnabled: false });
const res = await app.request(
`/v1/repos/${REPO}/ai-review`,
- { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "block", byok: true, provider: "anthropic", model: "claude-3-5-sonnet-latest", allAuthors: true, closeOwnerAuthors: true }) },
+ { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "block", byok: true, provider: "anthropic", model: "claude-3-5-sonnet-latest", allAuthors: true }) },
+ env,
+ );
+ expect(res.status).toBe(400);
+ expect(await res.json()).toMatchObject({ error: "invalid_ai_review_config" });
+ });
+
+ it("sets closeOwnerAuthors, sourcing aiReviewMode/byok/provider/model from the (unconfigured) manifest default", async () => {
+ const app = createApp();
+ const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET });
+ await upsertRepositorySettings(env, { repoFullName: REPO, autoLabelEnabled: false });
+ const res = await app.request(
+ `/v1/repos/${REPO}/ai-review`,
+ { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ closeOwnerAuthors: true }) },
env,
);
expect(res.status).toBe(200);
- expect(await res.json()).toMatchObject({ aiReviewMode: "block", aiReviewByok: true, aiReviewProvider: "anthropic", aiReviewModel: "claude-3-5-sonnet-latest", aiReviewAllAuthors: true, closeOwnerAuthors: true });
+ expect(await res.json()).toMatchObject({
+ aiReviewMode: "off",
+ aiReviewByok: false,
+ aiReviewProvider: null,
+ aiReviewModel: null,
+ aiReviewAllAuthors: false,
+ closeOwnerAuthors: true,
+ aiReviewConfigAsCode: true,
+ });
const settings = await getRepositorySettings(env, REPO);
- expect(settings.aiReviewMode).toBe("block");
- expect(settings.aiReviewAllAuthors).toBe(true); // persisted + read back (DB column round-trip)
expect(settings.closeOwnerAuthors).toBe(true); // persisted + read back (DB column round-trip)
- expect(settings.reviewCheckMode).toBe("required"); // preserved
expect(settings.autoLabelEnabled).toBe(false); // preserved
});
it("defaults closeOwnerAuthors off when the AI-review config omits it", async () => {
const app = createApp();
const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET });
- const res = await app.request(
- `/v1/repos/${REPO}/ai-review`,
- { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "block", byok: true, provider: "anthropic", model: "claude-3-5-sonnet-latest", allAuthors: true }) },
- env,
- );
+ const res = await app.request(`/v1/repos/${REPO}/ai-review`, { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({}) }, env);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ closeOwnerAuthors: false });
expect((await getRepositorySettings(env, REPO)).closeOwnerAuthors).toBe(false);
@@ -66,25 +82,13 @@ describe("maintainer AI-review config route", () => {
const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET });
await upsertRepositorySettings(env, { repoFullName: REPO, closeOwnerAuthors: true });
- const res = await app.request(
- `/v1/repos/${REPO}/ai-review`,
- { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "advisory", byok: false }) },
- env,
- );
+ const res = await app.request(`/v1/repos/${REPO}/ai-review`, { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({}) }, env);
expect(res.status).toBe(200);
- expect(await res.json()).toMatchObject({ aiReviewMode: "advisory", closeOwnerAuthors: true });
+ expect(await res.json()).toMatchObject({ closeOwnerAuthors: true });
expect((await getRepositorySettings(env, REPO)).closeOwnerAuthors).toBe(true);
});
- it("accepts a config without provider/model (stored as null)", async () => {
- const app = createApp();
- const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET });
- const res = await app.request(`/v1/repos/${REPO}/ai-review`, { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "advisory", byok: false }) }, env);
- expect(res.status).toBe(200);
- expect(await res.json()).toMatchObject({ aiReviewMode: "advisory", aiReviewByok: false, aiReviewProvider: null, aiReviewModel: null });
- });
-
it("rejects an invalid AI-review config", async () => {
const app = createApp();
const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET });
@@ -95,28 +99,23 @@ describe("maintainer AI-review config route", () => {
it("sets aiReviewLowConfidenceDisposition (#4603) and preserves unrelated settings", async () => {
const app = createApp();
const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET });
- await upsertRepositorySettings(env, { repoFullName: REPO, reviewCheckMode: "required", autoLabelEnabled: false });
+ await upsertRepositorySettings(env, { repoFullName: REPO, autoLabelEnabled: false });
const res = await app.request(
`/v1/repos/${REPO}/ai-review`,
- { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "block", byok: false, lowConfidenceDisposition: "advisory_only" }) },
+ { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ lowConfidenceDisposition: "advisory_only" }) },
env,
);
expect(res.status).toBe(200);
- expect(await res.json()).toMatchObject({ aiReviewMode: "block", aiReviewLowConfidenceDisposition: "advisory_only" });
+ expect(await res.json()).toMatchObject({ aiReviewLowConfidenceDisposition: "advisory_only" });
const settings = await getRepositorySettings(env, REPO);
expect(settings.aiReviewLowConfidenceDisposition).toBe("advisory_only"); // persisted + read back (DB column round-trip)
- expect(settings.reviewCheckMode).toBe("required"); // preserved
expect(settings.autoLabelEnabled).toBe(false); // preserved
});
it("defaults aiReviewLowConfidenceDisposition to hold_for_review when the AI-review config omits it (fresh repo, no row)", async () => {
const app = createApp();
const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET });
- const res = await app.request(
- `/v1/repos/${REPO}/ai-review`,
- { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "advisory", byok: false }) },
- env,
- );
+ const res = await app.request(`/v1/repos/${REPO}/ai-review`, { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({}) }, env);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ aiReviewLowConfidenceDisposition: "hold_for_review" });
expect((await getRepositorySettings(env, REPO)).aiReviewLowConfidenceDisposition).toBe("hold_for_review");
@@ -127,34 +126,30 @@ describe("maintainer AI-review config route", () => {
const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET });
await upsertRepositorySettings(env, { repoFullName: REPO, aiReviewLowConfidenceDisposition: "one_shot" });
- const res = await app.request(
- `/v1/repos/${REPO}/ai-review`,
- { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "advisory", byok: false }) },
- env,
- );
+ const res = await app.request(`/v1/repos/${REPO}/ai-review`, { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({}) }, env);
expect(res.status).toBe(200);
- expect(await res.json()).toMatchObject({ aiReviewMode: "advisory", aiReviewLowConfidenceDisposition: "one_shot" });
+ expect(await res.json()).toMatchObject({ aiReviewLowConfidenceDisposition: "one_shot" });
expect((await getRepositorySettings(env, REPO)).aiReviewLowConfidenceDisposition).toBe("one_shot");
});
it("rejects an invalid aiReviewLowConfidenceDisposition value", async () => {
const app = createApp();
const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET });
- const res = await app.request(`/v1/repos/${REPO}/ai-review`, { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "block", lowConfidenceDisposition: "sometimes" }) }, env);
+ const res = await app.request(`/v1/repos/${REPO}/ai-review`, { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ lowConfidenceDisposition: "sometimes" }) }, env);
expect(res.status).toBe(400);
});
it("lets maintainer settings set closeOwnerAuthors without resetting unrelated fields", async () => {
const app = createApp();
const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET });
- await upsertRepositorySettings(env, { repoFullName: REPO, reviewCheckMode: "required", autoLabelEnabled: false });
+ await upsertRepositorySettings(env, { repoFullName: REPO, autoLabelEnabled: false });
const res = await app.request(`/v1/repos/${REPO}/settings`, { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ closeOwnerAuthors: true }) }, env);
expect(res.status).toBe(200);
- expect(await res.json()).toMatchObject({ closeOwnerAuthors: true, reviewCheckMode: "required", autoLabelEnabled: false });
+ expect(await res.json()).toMatchObject({ closeOwnerAuthors: true, autoLabelEnabled: false });
const settings = await getRepositorySettings(env, REPO);
expect(settings.closeOwnerAuthors).toBe(true);
- expect(settings.reviewCheckMode).toBe("required");
+ expect(settings.autoLabelEnabled).toBe(false);
});
it("round-trips requireFreshRebaseWindowMinutes through the maintainer settings PUT route (#2552 gate finding)", async () => {
@@ -254,9 +249,9 @@ describe("maintainer route authz (session-scoped)", () => {
stubMinerFetch();
mockedPermission.mockResolvedValue("admin"); // real GitHub write access
const { token } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 201 });
- const res = await app.request(`${OWNED}/ai-review`, { method: "PUT", headers: { cookie: `loopover_session=${token}`, "content-type": "application/json" }, body: JSON.stringify({ mode: "advisory", byok: true, provider: "anthropic" }) }, env);
+ const res = await app.request(`${OWNED}/ai-review`, { method: "PUT", headers: { cookie: `loopover_session=${token}`, "content-type": "application/json" }, body: JSON.stringify({ closeOwnerAuthors: true }) }, env);
expect(res.status).toBe(200);
- expect(await res.json()).toMatchObject({ aiReviewMode: "advisory", aiReviewProvider: "anthropic" });
+ expect(await res.json()).toMatchObject({ closeOwnerAuthors: true, aiReviewConfigAsCode: true });
});
it("allows the repo owner (admin permission) via session to set a BYOK key", async () => {
diff --git a/test/unit/schema-timestamp-defaults.test.ts b/test/unit/schema-timestamp-defaults.test.ts
index 9b80af4906..d52e94deb5 100644
--- a/test/unit/schema-timestamp-defaults.test.ts
+++ b/test/unit/schema-timestamp-defaults.test.ts
@@ -26,12 +26,6 @@ describe("timestamp column defaults", () => {
expect(row?.createdAt).toMatch(ISO);
expect(row?.updatedAt).toMatch(ISO);
expect(row?.createdAt).not.toBe("CURRENT_TIMESTAMP");
- // #gate-review-2727: the raw SQLite column-level DEFAULT for linked_issue_gate_mode is still 'block'
- // (migration 0023 added it that way, and SQLite has no ALTER COLUMN SET DEFAULT to fix it without a full
- // table rebuild -- see migrations/0102_fix_linked_issue_gate_mode_default.sql's header comment). This
- // pins that the raw default never actually fires: Drizzle's schema.ts `.default("advisory")` is injected
- // client-side into the generated INSERT whenever the field is omitted, same as createdAt/updatedAt above.
- expect(row?.linkedIssueGateMode).toBe("advisory");
});
it("keeps orb relay pending coalesce keys wired through the drizzle schema", async () => {