fix(post-receive): committed to repos it could not identify, and silently skipped seven features on the rest #5493
2 changed files+217−20
Addedsrc/__tests__/post-receive-automation-gates.test.ts+134−0View fileUnifiedSplit
@@ -0,0 +1,134 @@
1/**
2 * post-receive automation gates — fail direction must be uniform and must
3 * follow the documented defaults.
4 *
5 * Regression for the 2026-08-12 defect: `onPostReceive` resolved per-repo
6 * automation settings to `null` when they could not be loaded, and each gate
7 * then picked its own fail direction. They disagreed:
8 *
9 * line 212 auto-repair `!automationSettings || isAutomationOn(...)` OPEN
10 * line 321 auto-issues `automationSettings && isAutomationOn(...)` CLOSED
11 * line 346 doc-drift `automationSettings && isAutomationOn(...)` CLOSED
12 *
13 * The gate that fails OPEN is the only one that WRITES — `autoRepair()`
14 * commits to the repo's default branch. So a repo whose settings could not be
15 * resolved got unreviewed `gluecron[bot]` commits on main, despite
16 * `autoRepairMode`'s documented default being "off".
17 *
18 * It was found on bookaride.co.nz / bookaridenz.com — two repos being handed
19 * to a buyer that day, each carrying a bot commit nobody asked for. The
20 * trigger was the repo lookup: `eq(repositories.name, repo)` is
21 * case-sensitive, so `BookARide` missed, `repoId` stayed "", settings stayed
22 * null, and the open gate fired.
23 *
24 * These tests read the source because `onPostReceive` needs a live DB and the
25 * suite has none — the same technique automation-settings.test.ts uses for
26 * dispatch sites. Source pins are the reliable way to assert a gate's shape
27 * when the function swallows every error by contract.
28 */
29
30import { describe, it, expect } from "bun:test";
31import { readFileSync } from "node:fs";
32import { join } from "node:path";
33
34import {
35 AUTOMATION_DEFAULTS,
36 isAutomationOn,
37} from "../lib/automation-settings";
38
39const RAW = readFileSync(
40 join(import.meta.dir, "../hooks/post-receive.ts"),
41 "utf8"
42);
43
44/**
45 * Comments stripped. The invariant is about what the code DOES, and the file
46 * deliberately quotes the old fail-open expressions in prose to explain the
47 * bug — asserting against the raw text would match that explanation and fail
48 * a correct file.
49 */
50const SRC = RAW.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
51
52describe("post-receive automation gates", () => {
53 it("never uses the fail-OPEN form for any gate", () => {
54 // `!automationSettings ||` is the exact shape of the bug: it turns
55 // "settings unknown" into "run it anyway".
56 expect(SRC).not.toContain("!automationSettings ||");
57 });
58
59 it("falls back to AUTOMATION_DEFAULTS, never to null", () => {
60 expect(SRC).toContain("AUTOMATION_DEFAULTS");
61 // The old resolution produced null on both the no-repoId and the throw
62 // path. Neither may return null again.
63 expect(SRC).not.toMatch(/getAutomationSettings\([^)]*\)\.catch\(\(\)\s*=>\s*null\)/);
64 expect(SRC).toContain(
65 "await getAutomationSettings(repoId).catch(() => AUTOMATION_DEFAULTS)"
66 );
67 });
68
69 it("gates all three automations uniformly on isAutomationOn", () => {
70 for (const mode of [
71 "autoRepairMode",
72 "autoIssuesMode",
73 "docDriftMode",
74 ]) {
75 expect(SRC).toContain(`isAutomationOn(automationSettings.${mode})`);
76 }
77 // No gate may still carry a null-guard, which would mean the fail
78 // direction is being decided at the call site again rather than by the
79 // defaults table.
80 expect(SRC).not.toContain("automationSettings &&");
81 });
82
83 it("resolves the repo case-insensitively so settings are actually found", () => {
84 // Confirmed trigger: the row is stored `ccantynz/bookaridenz` while the
85 // push arrived at `.../Bookaridenz.git`. repo-access.ts matches with
86 // `lower() = lower()` so the push is authorised, but this file's `eq()`
87 // missed — leaving repoId "" and the settings unresolvable.
88 expect(SRC).toContain("lower(${repositories.name}) = lower(${repo})");
89 expect(SRC).toContain("lower(${users.username}) = lower(${owner})");
90 expect(SRC).not.toContain("eq(repositories.name, repo)");
91 });
92
93 it("uses the case-insensitive lookup at EVERY repo-resolution site", () => {
94 // The same exact-match expression appeared 8 times. The other 7 each
95 // `return;` on no match, so a case mismatch silently disabled semantic
96 // indexing, preview builds, doc drift, server-target deploys, dependency
97 // scanning, repo onboarding and the vapron deploy trigger — no error, no
98 // log, just nothing happening. Fixing only the settings lookup would have
99 // left that whole class alive.
100 const insensitive = SRC.match(
101 /lower\(\$\{repositories\.name\}\) = lower\(\$\{repo\}\)/g
102 );
103 const sensitive = SRC.match(/eq\(repositories\.name, repo\)/g);
104 expect(sensitive).toBeNull();
105 expect(insensitive?.length ?? 0).toBeGreaterThanOrEqual(8);
106 });
107});
108
109describe("documented defaults are the authority", () => {
110 it("auto-repair is OFF by default, so unresolved settings never commit", () => {
111 // This is the property the gate now inherits: with settings defaulted
112 // rather than nulled, an unknown repo cannot get a bot commit.
113 expect(AUTOMATION_DEFAULTS.autoRepairMode).toBe("off");
114 expect(isAutomationOn(AUTOMATION_DEFAULTS.autoRepairMode)).toBe(false);
115 });
116
117 it("the other two write-ish automations are OFF by default too", () => {
118 expect(isAutomationOn(AUTOMATION_DEFAULTS.autoIssuesMode)).toBe(false);
119 expect(isAutomationOn(AUTOMATION_DEFAULTS.docDriftMode)).toBe(false);
120 });
121
122 it("defaulting only ever narrows what runs on an unconfigured repo", () => {
123 // Every mode the post-receive path gates on must default to "off".
124 // If a future default flips to "suggest", this test fails and forces the
125 // author to consider that unconfigured repos would start running it.
126 for (const mode of [
127 AUTOMATION_DEFAULTS.autoRepairMode,
128 AUTOMATION_DEFAULTS.autoIssuesMode,
129 AUTOMATION_DEFAULTS.docDriftMode,
130 ]) {
131 expect(mode).toBe("off");
132 }
133 });
134});
Modifiedsrc/hooks/post-receive.ts+83−20View fileUnifiedSplit
@@ -11,10 +11,15 @@
1111 */
1212
1313import { createHmac } from "crypto";
14import { and, eq } from "drizzle-orm";
14import { and, eq, sql } from "drizzle-orm";
1515import { config } from "../lib/config";
1616import { autoRepair } from "../lib/autorepair";
17import { getAutomationSettings, isAutomationOn } from "../lib/automation-settings";
17import {
18 AUTOMATION_DEFAULTS,
19 getAutomationSettings,
20 isAutomationOn,
21 type AutomationSettings,
22} from "../lib/automation-settings";
1823import { notifyGateTestOfPush } from "../lib/gate";
1924import { analyzePush, computeHealthScore } from "../lib/intelligence";
2025import { db } from "../db";
@@ -119,16 +124,39 @@ export async function onPostReceive(
119124 refs: PushRef[],
120125 pusherUserId: string = ""
121126): Promise<void> {
122 // Resolve per-repo automation settings once for this push. Fails open —
123 // any DB error leaves automationSettings null and all per-repo gates pass
124 // (unchanged behavior for repos without a settings row).
127 // Resolve per-repo automation settings once for this push.
128 //
129 // Unresolvable settings fall back to AUTOMATION_DEFAULTS, never to null.
130 // Previously this was null and each gate chose its own fail direction —
131 // and they disagreed: auto-issues and doc-drift used
132 // `automationSettings && …` (fail CLOSED) while auto-repair used
133 // `!automationSettings || …` (fail OPEN). The one that fails open is the
134 // one that WRITES a commit to the default branch, and it fired despite
135 // `autoRepairMode`'s documented default being "off".
136 //
137 // Found 2026-08-12: bookaride.co.nz and bookaridenz.com — two repos being
138 // handed to a buyer that day — each carried an unreviewed
139 // `gluecron[bot] "fix: auto-repair by gluecron"` commit on main that no
140 // one asked for. `repoId` resolves via `eq(repositories.name, repo)`,
141 // which is case-sensitive, so a capitalised repo name (BookARide) misses,
142 // leaves settings null, and opens the gate. Same "two sources of truth"
143 // family as issue #196.
144 //
145 // The defaults table is now the single authority: a repo with no settings
146 // row behaves exactly as the documented defaults say it should. All three
147 // gates below default to "off", so this only ever narrows what runs.
125148 let repoId = "";
126149 try {
127150 const [repoRow] = await db
128151 .select({ id: repositories.id })
129152 .from(repositories)
130153 .innerJoin(users, eq(repositories.ownerId, users.id))
131 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
154 .where(
155 and(
156 sql`lower(${users.username}) = lower(${owner})`,
157 sql`lower(${repositories.name}) = lower(${repo})`
158 )
159 )
132160 .limit(1);
133161 repoId = repoRow?.id ?? "";
134162 } catch { /* non-blocking */ }
@@ -152,9 +180,9 @@ export async function onPostReceive(
152180 });
153181 }
154182
155 const automationSettings = repoId
156 ? await getAutomationSettings(repoId).catch(() => null)
157 : null;
183 const automationSettings: AutomationSettings = repoId
184 ? await getAutomationSettings(repoId).catch(() => AUTOMATION_DEFAULTS)
185 : AUTOMATION_DEFAULTS;
158186
159187 for (const ref of refs) {
160188 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
@@ -208,8 +236,8 @@ export async function onPostReceive(
208236 }
209237 }
210238
211 // 1. Auto-repair — gated on per-repo autoRepairMode setting.
212 if (!automationSettings || isAutomationOn(automationSettings.autoRepairMode)) {
239 // 1. Auto-repair — gated on per-repo autoRepairMode setting (default off).
240 if (isAutomationOn(automationSettings.autoRepairMode)) {
213241 try {
214242 const repair = await withTestIsolation(() => autoRepair(owner, repo, branchName));
215243 if (repair.repaired) {
@@ -318,7 +346,7 @@ export async function onPostReceive(
318346 // per-repo autoIssuesMode. Fire-and-forget; never blocks the push path.
319347 for (const ref of refs) {
320348 if (ref.newSha.startsWith("0000")) continue;
321 if (automationSettings && isAutomationOn(automationSettings.autoIssuesMode)) {
349 if (isAutomationOn(automationSettings.autoIssuesMode)) {
322350 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
323351 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
324352 );
@@ -343,7 +371,7 @@ export async function onPostReceive(
343371 // docDriftMode. Fire-and-forget; failures are swallowed inside
344372 // ai-doc-updater.ts so a missing anthropic key or empty
345373 // doc_tracking table never breaks the push.
346 if (automationSettings && isAutomationOn(automationSettings.docDriftMode)) {
374 if (isAutomationOn(automationSettings.docDriftMode)) {
347375 void fireDocDriftCheck(owner, repo).catch((err) =>
348376 console.warn("[ai-doc-updater] dispatch error:", err)
349377 );
@@ -383,7 +411,12 @@ export async function onPostReceive(
383411 .select({ id: repositories.id })
384412 .from(repositories)
385413 .innerJoin(users, eq(repositories.ownerId, users.id))
386 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
414 .where(
415 and(
416 sql`lower(${users.username}) = lower(${owner})`,
417 sql`lower(${repositories.name}) = lower(${repo})`
418 )
419 )
387420 .limit(1);
388421 repositoryId = row?.id || "";
389422 } catch {
@@ -714,7 +747,12 @@ async function fireSemanticIndex(
714747 .select({ id: repositories.id })
715748 .from(repositories)
716749 .innerJoin(users, eq(repositories.ownerId, users.id))
717 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
750 .where(
751 and(
752 sql`lower(${users.username}) = lower(${owner})`,
753 sql`lower(${repositories.name}) = lower(${repo})`
754 )
755 )
718756 .limit(1);
719757 repositoryId = row?.id || "";
720758 } catch {
@@ -812,7 +850,12 @@ async function firePreviewBuilds(
812850 })
813851 .from(repositories)
814852 .innerJoin(users, eq(repositories.ownerId, users.id))
815 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
853 .where(
854 and(
855 sql`lower(${users.username}) = lower(${owner})`,
856 sql`lower(${repositories.name}) = lower(${repo})`
857 )
858 )
816859 .limit(1);
817860 repoRow = row || null;
818861 } catch {
@@ -853,7 +896,12 @@ async function fireDocDriftCheck(owner: string, repo: string): Promise<void> {
853896 .select({ id: repositories.id })
854897 .from(repositories)
855898 .innerJoin(users, eq(repositories.ownerId, users.id))
856 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
899 .where(
900 and(
901 sql`lower(${users.username}) = lower(${owner})`,
902 sql`lower(${repositories.name}) = lower(${repo})`
903 )
904 )
857905 .limit(1);
858906 repositoryId = row?.id || "";
859907 } catch {
@@ -895,7 +943,12 @@ async function fireServerTargetDeploys(
895943 .select({ id: repositories.id })
896944 .from(repositories)
897945 .innerJoin(users, eq(repositories.ownerId, users.id))
898 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
946 .where(
947 and(
948 sql`lower(${users.username}) = lower(${owner})`,
949 sql`lower(${repositories.name}) = lower(${repo})`
950 )
951 )
899952 .limit(1);
900953 repositoryId = row?.id || "";
901954 } catch {
@@ -972,7 +1025,12 @@ async function fireDependencyScan(
9721025 .select({ id: repositories.id, ownerId: repositories.ownerId })
9731026 .from(repositories)
9741027 .innerJoin(users, eq(repositories.ownerId, users.id))
975 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
1028 .where(
1029 and(
1030 sql`lower(${users.username}) = lower(${owner})`,
1031 sql`lower(${repositories.name}) = lower(${repo})`
1032 )
1033 )
9761034 .limit(1);
9771035 repoRow = row || null;
9781036 } catch {
@@ -1028,7 +1086,12 @@ async function fireRepoOnboarding(
10281086 .select({ id: repositories.id })
10291087 .from(repositories)
10301088 .innerJoin(users, eq(repositories.ownerId, users.id))
1031 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
1089 .where(
1090 and(
1091 sql`lower(${users.username}) = lower(${owner})`,
1092 sql`lower(${repositories.name}) = lower(${repo})`
1093 )
1094 )
10321095 .limit(1);
10331096 repositoryId = row?.id || "";
10341097 } catch {
10351098
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts