Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

automation-settings.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

automation-settings.test.tsBlame270 lines · 1 contributor
479dcd9Claude1/**
2 * Per-repo automation settings tests (migration 0106).
3 *
4 * Covers the contract in src/lib/automation-settings.ts:
5 * - defaults when no row exists / when the DB lookup fails (fail-open)
6 * - pure mode resolution: normalizeMode, resolveEffectiveMode (env
7 * kill-switches stay supreme), isAutomationOn, settingsFromRow
8 * - dispatch-site gating with mocked settings via the same DI seams the
9 * auto-merge / ci-autofix suites use:
10 * * evaluateAutoMerge respects 'off' / 'suggest' (no DB touched)
11 * * the other dispatch sites are pinned with the readFileSync source
12 * wiring technique used by repair-flywheel-wiring.test.ts
13 *
14 * No DB, git, or Anthropic calls are made anywhere in this suite.
15 */
16
17import { describe, it, expect } from "bun:test";
18import { readFileSync } from "node:fs";
19import { join } from "node:path";
20
21import {
22 AUTOMATION_DEFAULTS,
23 getAutomationSettings,
24 isAutomationOn,
25 normalizeMode,
26 resolveEffectiveMode,
27 settingsFromRow,
28 type AutomationSettings,
29 type AutomationSettingsLoader,
30} from "../lib/automation-settings";
31import { evaluateAutoMerge } from "../lib/auto-merge";
32
33const REPO_ID = "11111111-2222-3333-4444-555555555555";
34
35function makeSettings(
36 overrides: Partial<AutomationSettings> = {}
37): AutomationSettings {
38 return { ...AUTOMATION_DEFAULTS, ...overrides };
39}
40
41/** Loader stub that records calls — the DI seam every dispatch site accepts. */
42function makeLoader(settings: AutomationSettings): {
43 loader: AutomationSettingsLoader;
44 calls: string[];
45} {
46 const calls: string[] = [];
47 return {
48 loader: async (repositoryId: string) => {
49 calls.push(repositoryId);
50 return settings;
51 },
52 calls,
53 };
54}
55
56// ---------------------------------------------------------------------------
57// Defaults
58// ---------------------------------------------------------------------------
59
60describe("AUTOMATION_DEFAULTS — match pre-0106 behavior", () => {
61 it("advisory features default to 'suggest'", () => {
62 expect(AUTOMATION_DEFAULTS.aiReviewMode).toBe("suggest");
63 expect(AUTOMATION_DEFAULTS.prTriageMode).toBe("suggest");
64 expect(AUTOMATION_DEFAULTS.issueTriageMode).toBe("suggest");
65 expect(AUTOMATION_DEFAULTS.ciAutofixMode).toBe("suggest");
66 });
67
64aa989Claude68 it("auto-merge defaults to 'off' — developers opt in, not opt out", () => {
69 expect(AUTOMATION_DEFAULTS.autoMergeMode).toBe("off");
70 });
71
72 it("auto-repair defaults to 'suggest' (runs but preserves pre-0107 behavior)", () => {
73 expect(AUTOMATION_DEFAULTS.autoRepairMode).toBe("suggest");
479dcd9Claude74 });
75});
76
77describe("getAutomationSettings — fail-open to defaults", () => {
78 it("returns the defaults when the DB is unavailable (no row, no env)", async () => {
79 // The test runner has no DATABASE_URL, so the lazy DB proxy throws on
80 // first access — getAutomationSettings must swallow that and return
81 // the defaults rather than letting the error change behavior.
82 const settings = await getAutomationSettings(REPO_ID);
83 expect(settings).toEqual({ ...AUTOMATION_DEFAULTS });
84 });
85});
86
87// ---------------------------------------------------------------------------
88// Pure mode resolution
89// ---------------------------------------------------------------------------
90
91describe("normalizeMode", () => {
92 it("passes through the three valid modes", () => {
93 expect(normalizeMode("off", "suggest")).toBe("off");
94 expect(normalizeMode("suggest", "off")).toBe("suggest");
95 expect(normalizeMode("auto", "off")).toBe("auto");
96 });
97
98 it("falls back on garbage, undefined, and non-strings", () => {
99 expect(normalizeMode("ON", "suggest")).toBe("suggest");
100 expect(normalizeMode(undefined, "auto")).toBe("auto");
101 expect(normalizeMode(null, "off")).toBe("off");
102 expect(normalizeMode(1, "suggest")).toBe("suggest");
103 expect(normalizeMode("", "suggest")).toBe("suggest");
104 });
105});
106
107describe("resolveEffectiveMode — env kill-switches stay supreme", () => {
108 it("env off forces 'off' regardless of the repo mode", () => {
109 expect(resolveEffectiveMode("auto", false)).toBe("off");
110 expect(resolveEffectiveMode("suggest", false)).toBe("off");
111 expect(resolveEffectiveMode("off", false)).toBe("off");
112 });
113
114 it("env on yields the repo mode unchanged (repo can only narrow)", () => {
115 expect(resolveEffectiveMode("auto", true)).toBe("auto");
116 expect(resolveEffectiveMode("suggest", true)).toBe("suggest");
117 expect(resolveEffectiveMode("off", true)).toBe("off");
118 });
119});
120
121describe("isAutomationOn — 'suggest' and 'auto' both count as on", () => {
122 it("only 'off' is off", () => {
123 expect(isAutomationOn("off")).toBe(false);
124 expect(isAutomationOn("suggest")).toBe(true);
125 expect(isAutomationOn("auto")).toBe(true);
126 });
127});
128
129describe("settingsFromRow", () => {
130 it("null/undefined row → defaults", () => {
131 expect(settingsFromRow(null)).toEqual({ ...AUTOMATION_DEFAULTS });
132 expect(settingsFromRow(undefined)).toEqual({ ...AUTOMATION_DEFAULTS });
133 });
134
135 it("valid row values pass through", () => {
136 const out = settingsFromRow({
137 aiReviewMode: "off",
138 prTriageMode: "off",
139 issueTriageMode: "suggest",
140 autoMergeMode: "suggest",
141 ciAutofixMode: "auto",
142 });
143 expect(out.aiReviewMode).toBe("off");
144 expect(out.prTriageMode).toBe("off");
145 expect(out.issueTriageMode).toBe("suggest");
146 expect(out.autoMergeMode).toBe("suggest");
147 expect(out.ciAutofixMode).toBe("auto");
148 });
149
150 it("corrupt per-field values fall back per-field to the defaults", () => {
151 const out = settingsFromRow({
152 aiReviewMode: "banana",
153 autoMergeMode: "off",
154 });
155 expect(out.aiReviewMode).toBe("suggest"); // default
156 expect(out.autoMergeMode).toBe("off"); // valid value kept
157 expect(out.ciAutofixMode).toBe("suggest"); // missing → default
158 });
159});
160
161// ---------------------------------------------------------------------------
162// Dispatch-site gating — auto-merge (behavioral, via the DI seam)
163// ---------------------------------------------------------------------------
164
165describe("evaluateAutoMerge — per-repo automation gate", () => {
166 const ctx = {
167 pullRequestId: "pr-1",
168 repositoryId: REPO_ID,
169 baseBranch: "main",
170 isDraft: false,
171 authorUserId: "user-1",
172 };
173
174 it("'off' blocks the merge before any DB work", async () => {
175 const { loader, calls } = makeLoader(makeSettings({ autoMergeMode: "off" }));
176 const decision = await evaluateAutoMerge(ctx, {
177 loadAutomationSettings: loader,
178 });
179 expect(decision.merge).toBe(false);
180 expect(decision.reason).toContain("turned off");
181 expect(decision.blocking?.length).toBe(1);
182 expect(calls).toEqual([REPO_ID]);
183 });
184
185 it("'suggest' evaluates to merge:false with a human-handoff reason", async () => {
186 const { loader } = makeLoader(makeSettings({ autoMergeMode: "suggest" }));
187 const decision = await evaluateAutoMerge(ctx, {
188 loadAutomationSettings: loader,
189 });
190 expect(decision.merge).toBe(false);
191 expect(decision.reason).toContain("suggest");
192 expect(decision.reason).toContain("merge left to a human");
193 });
194
195 it("'auto' proceeds past the gate into the K2 evaluation", async () => {
196 const { loader, calls } = makeLoader(makeSettings({ autoMergeMode: "auto" }));
197 // With no DATABASE_URL the downstream matchProtection lookup fails;
198 // the contract here is only that the gate did NOT short-circuit —
199 // i.e. the loader was consulted and the decision (whatever the DB
200 // state yields) is not the automation-settings refusal.
201 let decision: { merge: boolean; reason: string } | null = null;
202 try {
203 decision = await evaluateAutoMerge(ctx, {
204 loadAutomationSettings: loader,
205 });
206 } catch {
207 // Downstream DB failure is acceptable in this DB-less environment.
208 }
209 expect(calls).toEqual([REPO_ID]);
210 if (decision) {
211 expect(decision.reason).not.toContain("automation settings");
212 }
213 });
214});
215
216// ---------------------------------------------------------------------------
217// Dispatch-site gating — source wiring pins for the fire-and-forget paths
218// (same readFileSync technique as repair-flywheel-wiring.test.ts; these
219// functions swallow every error by contract, so source pins are the
220// reliable way to assert the gate exists and sits on the live path).
221// ---------------------------------------------------------------------------
222
223describe("dispatch-site source wiring", () => {
224 const read = (rel: string) =>
225 readFileSync(join(import.meta.dir, rel), "utf8");
226
227 it("ai-review: triggerAiReview consults the per-repo setting and skips on 'off'", () => {
228 const src = read("../lib/ai-review.ts");
229 expect(src).toContain('from "./automation-settings"');
230 expect(src).toContain("options.loadSettings ?? getAutomationSettings");
231 expect(src).toContain('if (automation.aiReviewMode === "off") return;');
232 });
233
234 it("pr-triage: triggerPrTriage consults the per-repo setting and skips on 'off'", () => {
235 const src = read("../lib/pr-triage.ts");
236 expect(src).toContain('from "./automation-settings"');
237 expect(src).toContain('if (automation.prTriageMode === "off") return;');
238 });
239
240 it("issue-triage: triggerIssueTriage consults the per-repo setting and skips on 'off'", () => {
241 const src = read("../lib/issue-triage.ts");
242 expect(src).toContain('from "./automation-settings"');
243 expect(src).toContain('if (automation.issueTriageMode === "off") return;');
244 });
245
246 it("ci-autofix: _runAutofix skips on 'off' and auto-applies on 'auto'", () => {
247 const src = read("../lib/ci-autofix.ts");
248 expect(src).toContain('from "./automation-settings"');
249 expect(src).toContain('if (automation.ciAutofixMode === "off") return;');
250 expect(src).toContain('automation.ciAutofixMode === "auto"');
251 expect(src).toContain("applyAutofix(posted.id, repoRow.ownerId, deps)");
252 });
253
254 it("env guards stay supreme at the triage/review dispatch sites", () => {
255 // The per-repo gate must sit BEHIND the existing env kill-switches so
256 // a repo setting can never widen what the environment allows.
257 for (const rel of ["../lib/pr-triage.ts", "../lib/issue-triage.ts"]) {
258 const src = read(rel);
259 const envIdx = src.indexOf("if (!isAiAvailable()) return;");
260 const gateIdx = src.indexOf("options.loadSettings ?? getAutomationSettings");
261 expect(envIdx).toBeGreaterThan(-1);
262 expect(gateIdx).toBeGreaterThan(envIdx);
263 }
264 const review = read("../lib/ai-review.ts");
265 const envIdx = review.indexOf("if (!isAiReviewEnabled()) return;");
266 const gateIdx = review.indexOf("options.loadSettings ?? getAutomationSettings");
267 expect(envIdx).toBeGreaterThan(-1);
268 expect(gateIdx).toBeGreaterThan(envIdx);
269 });
270});