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