Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsBlame278 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
f183fdfClaude72 it("auto-repair defaults to 'off' — bot never commits to branches unless owner enables it", () => {
73 expect(AUTOMATION_DEFAULTS.autoRepairMode).toBe("off");
74 });
75
76 it("auto-issues defaults to 'off' — off by default, owner opts in", () => {
77 expect(AUTOMATION_DEFAULTS.autoIssuesMode).toBe("off");
78 });
79
80 it("doc-drift defaults to 'off' — off by default, owner opts in", () => {
81 expect(AUTOMATION_DEFAULTS.docDriftMode).toBe("off");
479dcd9Claude82 });
83});
84
85describe("getAutomationSettings — fail-open to defaults", () => {
86 it("returns the defaults when the DB is unavailable (no row, no env)", async () => {
87 // The test runner has no DATABASE_URL, so the lazy DB proxy throws on
88 // first access — getAutomationSettings must swallow that and return
89 // the defaults rather than letting the error change behavior.
90 const settings = await getAutomationSettings(REPO_ID);
91 expect(settings).toEqual({ ...AUTOMATION_DEFAULTS });
92 });
93});
94
95// ---------------------------------------------------------------------------
96// Pure mode resolution
97// ---------------------------------------------------------------------------
98
99describe("normalizeMode", () => {
100 it("passes through the three valid modes", () => {
101 expect(normalizeMode("off", "suggest")).toBe("off");
102 expect(normalizeMode("suggest", "off")).toBe("suggest");
103 expect(normalizeMode("auto", "off")).toBe("auto");
104 });
105
106 it("falls back on garbage, undefined, and non-strings", () => {
107 expect(normalizeMode("ON", "suggest")).toBe("suggest");
108 expect(normalizeMode(undefined, "auto")).toBe("auto");
109 expect(normalizeMode(null, "off")).toBe("off");
110 expect(normalizeMode(1, "suggest")).toBe("suggest");
111 expect(normalizeMode("", "suggest")).toBe("suggest");
112 });
113});
114
115describe("resolveEffectiveMode — env kill-switches stay supreme", () => {
116 it("env off forces 'off' regardless of the repo mode", () => {
117 expect(resolveEffectiveMode("auto", false)).toBe("off");
118 expect(resolveEffectiveMode("suggest", false)).toBe("off");
119 expect(resolveEffectiveMode("off", false)).toBe("off");
120 });
121
122 it("env on yields the repo mode unchanged (repo can only narrow)", () => {
123 expect(resolveEffectiveMode("auto", true)).toBe("auto");
124 expect(resolveEffectiveMode("suggest", true)).toBe("suggest");
125 expect(resolveEffectiveMode("off", true)).toBe("off");
126 });
127});
128
129describe("isAutomationOn — 'suggest' and 'auto' both count as on", () => {
130 it("only 'off' is off", () => {
131 expect(isAutomationOn("off")).toBe(false);
132 expect(isAutomationOn("suggest")).toBe(true);
133 expect(isAutomationOn("auto")).toBe(true);
134 });
135});
136
137describe("settingsFromRow", () => {
138 it("null/undefined row → defaults", () => {
139 expect(settingsFromRow(null)).toEqual({ ...AUTOMATION_DEFAULTS });
140 expect(settingsFromRow(undefined)).toEqual({ ...AUTOMATION_DEFAULTS });
141 });
142
143 it("valid row values pass through", () => {
144 const out = settingsFromRow({
145 aiReviewMode: "off",
146 prTriageMode: "off",
147 issueTriageMode: "suggest",
148 autoMergeMode: "suggest",
149 ciAutofixMode: "auto",
150 });
151 expect(out.aiReviewMode).toBe("off");
152 expect(out.prTriageMode).toBe("off");
153 expect(out.issueTriageMode).toBe("suggest");
154 expect(out.autoMergeMode).toBe("suggest");
155 expect(out.ciAutofixMode).toBe("auto");
156 });
157
158 it("corrupt per-field values fall back per-field to the defaults", () => {
159 const out = settingsFromRow({
160 aiReviewMode: "banana",
161 autoMergeMode: "off",
162 });
163 expect(out.aiReviewMode).toBe("suggest"); // default
164 expect(out.autoMergeMode).toBe("off"); // valid value kept
165 expect(out.ciAutofixMode).toBe("suggest"); // missing → default
166 });
167});
168
169// ---------------------------------------------------------------------------
170// Dispatch-site gating — auto-merge (behavioral, via the DI seam)
171// ---------------------------------------------------------------------------
172
173describe("evaluateAutoMerge — per-repo automation gate", () => {
174 const ctx = {
175 pullRequestId: "pr-1",
176 repositoryId: REPO_ID,
177 baseBranch: "main",
178 isDraft: false,
179 authorUserId: "user-1",
180 };
181
182 it("'off' blocks the merge before any DB work", async () => {
183 const { loader, calls } = makeLoader(makeSettings({ autoMergeMode: "off" }));
184 const decision = await evaluateAutoMerge(ctx, {
185 loadAutomationSettings: loader,
186 });
187 expect(decision.merge).toBe(false);
188 expect(decision.reason).toContain("turned off");
189 expect(decision.blocking?.length).toBe(1);
190 expect(calls).toEqual([REPO_ID]);
191 });
192
193 it("'suggest' evaluates to merge:false with a human-handoff reason", async () => {
194 const { loader } = makeLoader(makeSettings({ autoMergeMode: "suggest" }));
195 const decision = await evaluateAutoMerge(ctx, {
196 loadAutomationSettings: loader,
197 });
198 expect(decision.merge).toBe(false);
199 expect(decision.reason).toContain("suggest");
200 expect(decision.reason).toContain("merge left to a human");
201 });
202
203 it("'auto' proceeds past the gate into the K2 evaluation", async () => {
204 const { loader, calls } = makeLoader(makeSettings({ autoMergeMode: "auto" }));
205 // With no DATABASE_URL the downstream matchProtection lookup fails;
206 // the contract here is only that the gate did NOT short-circuit —
207 // i.e. the loader was consulted and the decision (whatever the DB
208 // state yields) is not the automation-settings refusal.
209 let decision: { merge: boolean; reason: string } | null = null;
210 try {
211 decision = await evaluateAutoMerge(ctx, {
212 loadAutomationSettings: loader,
213 });
214 } catch {
215 // Downstream DB failure is acceptable in this DB-less environment.
216 }
217 expect(calls).toEqual([REPO_ID]);
218 if (decision) {
219 expect(decision.reason).not.toContain("automation settings");
220 }
221 });
222});
223
224// ---------------------------------------------------------------------------
225// Dispatch-site gating — source wiring pins for the fire-and-forget paths
226// (same readFileSync technique as repair-flywheel-wiring.test.ts; these
227// functions swallow every error by contract, so source pins are the
228// reliable way to assert the gate exists and sits on the live path).
229// ---------------------------------------------------------------------------
230
231describe("dispatch-site source wiring", () => {
232 const read = (rel: string) =>
233 readFileSync(join(import.meta.dir, rel), "utf8");
234
235 it("ai-review: triggerAiReview consults the per-repo setting and skips on 'off'", () => {
236 const src = read("../lib/ai-review.ts");
237 expect(src).toContain('from "./automation-settings"');
238 expect(src).toContain("options.loadSettings ?? getAutomationSettings");
239 expect(src).toContain('if (automation.aiReviewMode === "off") return;');
240 });
241
242 it("pr-triage: triggerPrTriage consults the per-repo setting and skips on 'off'", () => {
243 const src = read("../lib/pr-triage.ts");
244 expect(src).toContain('from "./automation-settings"');
245 expect(src).toContain('if (automation.prTriageMode === "off") return;');
246 });
247
248 it("issue-triage: triggerIssueTriage consults the per-repo setting and skips on 'off'", () => {
249 const src = read("../lib/issue-triage.ts");
250 expect(src).toContain('from "./automation-settings"');
251 expect(src).toContain('if (automation.issueTriageMode === "off") return;');
252 });
253
254 it("ci-autofix: _runAutofix skips on 'off' and auto-applies on 'auto'", () => {
255 const src = read("../lib/ci-autofix.ts");
256 expect(src).toContain('from "./automation-settings"');
257 expect(src).toContain('if (automation.ciAutofixMode === "off") return;');
258 expect(src).toContain('automation.ciAutofixMode === "auto"');
259 expect(src).toContain("applyAutofix(posted.id, repoRow.ownerId, deps)");
260 });
261
262 it("env guards stay supreme at the triage/review dispatch sites", () => {
263 // The per-repo gate must sit BEHIND the existing env kill-switches so
264 // a repo setting can never widen what the environment allows.
265 for (const rel of ["../lib/pr-triage.ts", "../lib/issue-triage.ts"]) {
266 const src = read(rel);
267 const envIdx = src.indexOf("if (!isAiAvailable()) return;");
268 const gateIdx = src.indexOf("options.loadSettings ?? getAutomationSettings");
269 expect(envIdx).toBeGreaterThan(-1);
270 expect(gateIdx).toBeGreaterThan(envIdx);
271 }
272 const review = read("../lib/ai-review.ts");
273 const envIdx = review.indexOf("if (!isAiReviewEnabled()) return;");
274 const gateIdx = review.indexOf("options.loadSettings ?? getAutomationSettings");
275 expect(envIdx).toBeGreaterThan(-1);
276 expect(gateIdx).toBeGreaterThan(envIdx);
277 });
278});