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

enable-auto-merge.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.

enable-auto-merge.tsBlame385 lines · 1 contributor
f764c07Claude1/**
2 * Block N1 — Enable auto-merge on a single repo + branch pattern.
3 *
4 * Operator-driven, single-shot bootstrap script. After PR #62 lands and
5 * the K3 autopilot sweep is live, the operator flips one repo into
6 * "auto-merge mode" with:
7 *
8 * bun run scripts/enable-auto-merge.ts ccantynz/Gluecron.com
9 * bun run scripts/enable-auto-merge.ts ccantynz/Gluecron.com release/*
10 *
11 * Behaviour (idempotent):
12 * 1. Resolve repo by `owner/name`. Fail loudly if not found.
13 * 2. If a `branch_protection` row already exists for (repo, pattern):
14 * flip `enableAutoMerge=true` (only that field — everything else
15 * stays as the owner configured it).
16 * 3. If no row exists: INSERT a new row with the safety defaults below,
17 * with `enableAutoMerge=true`.
18 * 4. Print a before / after diff so the operator sees exactly what
19 * changed.
20 * 5. Write an `auto_merge.enabled_on_main` audit row so we can trace
21 * who flipped the switch.
22 *
23 * Safety defaults on a fresh insert:
24 * - pattern = the supplied branch (default `main`)
25 * - requireGreenGates = true (no flaky tests slipping through)
26 * - requireAiApproval = true (Claude must approve — that's the point)
27 * - requireHumanReview = false (otherwise auto-merge can't fire)
28 * - requiredApprovals = 0
29 * - enableAutoMerge = true
30 * - dismissStaleReviews = false (we don't want stale-review churn)
31 * - requirePullRequest = true
32 * - allowForcePush = false
33 * - allowDeletion = false
34 *
35 * Revert: re-run with `--off`, or hand-toggle the column.
36 *
37 * Reads DATABASE_URL from env. The pure orchestrator `runEnableAutoMerge`
38 * is exported and takes a DB-shaped dependency so tests can drive every
39 * branch without touching Neon.
40 */
41
42import { and, eq } from "drizzle-orm";
43import {
44 branchProtection,
45 repositories,
46 users,
47 auditLog,
48} from "../src/db/schema";
49import type { BranchProtection } from "../src/db/schema";
50
51// ---------------------------------------------------------------------------
52// Types
53// ---------------------------------------------------------------------------
54
55export interface EnableAutoMergeArgs {
56 ownerSlash: string; // "owner/name"
57 pattern: string; // e.g. "main" or "release/*"
58 off?: boolean; // when true, set enableAutoMerge=false instead
59 actorUserId?: string | null; // optional audit attribution
60}
61
62export interface EnableAutoMergeResult {
63 action: "inserted" | "updated" | "noop";
64 before: BranchProtection | null;
65 after: BranchProtection;
66 auditWritten: boolean;
67}
68
69/**
70 * Minimal DB surface this script needs. Mirrors the Drizzle methods used
71 * directly so tests can supply a fake without standing up a real Drizzle
72 * instance.
73 */
74export interface DbLike {
75 select: (...args: any[]) => any;
76 insert: (...args: any[]) => any;
77 update: (...args: any[]) => any;
78}
79
80// ---------------------------------------------------------------------------
81// Core orchestrator
82// ---------------------------------------------------------------------------
83
84const SAFETY_DEFAULTS = {
85 requirePullRequest: true,
86 requireGreenGates: true,
87 requireAiApproval: true,
88 requireHumanReview: false,
89 requiredApprovals: 0,
90 allowForcePush: false,
91 allowDeletion: false,
92 dismissStaleReviews: false,
93} as const;
94
95/**
96 * Resolve a repo row by `owner/name`. Returns null when either the owner
97 * or the repo is missing — the caller turns that into a clean exit code.
98 */
99export async function resolveRepo(
100 db: DbLike,
101 ownerSlash: string
102): Promise<{ id: string; ownerId: string; name: string; defaultBranch: string } | null> {
103 const slashIdx = ownerSlash.indexOf("/");
104 if (slashIdx <= 0 || slashIdx === ownerSlash.length - 1) return null;
105 const ownerName = ownerSlash.slice(0, slashIdx);
106 const repoName = ownerSlash.slice(slashIdx + 1);
107
108 const ownerRows = await db
109 .select({ id: users.id })
110 .from(users)
111 .where(eq(users.username, ownerName))
112 .limit(1);
113 const owner = (ownerRows as Array<{ id: string }>)[0];
114 if (!owner) return null;
115
116 const repoRows = await db
117 .select({
118 id: repositories.id,
119 ownerId: repositories.ownerId,
120 name: repositories.name,
121 defaultBranch: repositories.defaultBranch,
122 })
123 .from(repositories)
124 .where(
125 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
126 )
127 .limit(1);
128 const repo = (repoRows as Array<{
129 id: string;
130 ownerId: string;
131 name: string;
132 defaultBranch: string;
133 }>)[0];
134 return repo ?? null;
135}
136
137/**
138 * Pure orchestrator. All DB access goes through `db` so tests can inject
139 * a fake. Returns the before/after rows so the CLI can render a diff and
140 * the test suite can assert on the transition.
141 */
142export async function runEnableAutoMerge(
143 db: DbLike,
144 args: EnableAutoMergeArgs,
145 audit: (opts: {
146 userId?: string | null;
147 repositoryId?: string | null;
148 action: string;
149 targetType?: string;
150 targetId?: string;
151 metadata?: Record<string, unknown>;
152 }) => Promise<void>
153): Promise<EnableAutoMergeResult> {
154 const repo = await resolveRepo(db, args.ownerSlash);
155 if (!repo) {
156 throw new Error(
157 `Repository not found: ${args.ownerSlash}. Pass owner/name (e.g. ccantynz/Gluecron.com).`
158 );
159 }
160
161 const desiredEnableAutoMerge = !args.off;
162
163 // Look up existing rule for this (repo, pattern).
164 const existingRows = await db
165 .select()
166 .from(branchProtection)
167 .where(
168 and(
169 eq(branchProtection.repositoryId, repo.id),
170 eq(branchProtection.pattern, args.pattern)
171 )
172 )
173 .limit(1);
174 const existing = (existingRows as BranchProtection[])[0] ?? null;
175
176 if (existing) {
177 // Idempotent: when the row is already in the desired state, skip
178 // both the UPDATE and the audit write. Operators running the script
179 // twice in a row should see "no-op", not a noisy audit trail.
180 if (existing.enableAutoMerge === desiredEnableAutoMerge) {
181 return {
182 action: "noop",
183 before: existing,
184 after: existing,
185 auditWritten: false,
186 };
187 }
188
189 // Snapshot BEFORE the mutation — `existing` may be the same row
190 // reference the DB layer hands to `update().set()`, so we copy here
191 // to keep the returned `before` field stable for callers.
192 const beforeSnapshot: BranchProtection = { ...existing };
193
194 const now = new Date();
195 await db
196 .update(branchProtection)
197 .set({ enableAutoMerge: desiredEnableAutoMerge, updatedAt: now })
198 .where(eq(branchProtection.id, existing.id));
199
200 const after: BranchProtection = {
201 ...beforeSnapshot,
202 enableAutoMerge: desiredEnableAutoMerge,
203 updatedAt: now,
204 };
205
206 await audit({
207 userId: args.actorUserId ?? null,
208 repositoryId: repo.id,
209 action: desiredEnableAutoMerge
210 ? "auto_merge.enabled_on_main"
211 : "auto_merge.disabled_on_main",
212 targetType: "branch_protection",
213 targetId: existing.id,
214 metadata: {
215 pattern: args.pattern,
216 before: { enableAutoMerge: beforeSnapshot.enableAutoMerge },
217 after: { enableAutoMerge: desiredEnableAutoMerge },
218 },
219 });
220
221 return {
222 action: "updated",
223 before: beforeSnapshot,
224 after,
225 auditWritten: true,
226 };
227 }
228
229 // No existing row — INSERT a new one with the documented safety
230 // defaults plus the requested auto-merge bit.
231 const inserted = await db
232 .insert(branchProtection)
233 .values({
234 repositoryId: repo.id,
235 pattern: args.pattern,
236 ...SAFETY_DEFAULTS,
237 enableAutoMerge: desiredEnableAutoMerge,
238 })
239 .returning();
240 const after = (inserted as BranchProtection[])[0];
241 if (!after) {
242 throw new Error(
243 "INSERT into branch_protection returned no row — refusing to record audit."
244 );
245 }
246
247 await audit({
248 userId: args.actorUserId ?? null,
249 repositoryId: repo.id,
250 action: desiredEnableAutoMerge
251 ? "auto_merge.enabled_on_main"
252 : "auto_merge.disabled_on_main",
253 targetType: "branch_protection",
254 targetId: after.id,
255 metadata: {
256 pattern: args.pattern,
257 created: true,
258 defaults: { ...SAFETY_DEFAULTS, enableAutoMerge: desiredEnableAutoMerge },
259 },
260 });
261
262 return {
263 action: "inserted",
264 before: null,
265 after,
266 auditWritten: true,
267 };
268}
269
270// ---------------------------------------------------------------------------
271// Diff renderer (pure, test-friendly)
272// ---------------------------------------------------------------------------
273
274const TRACKED_FIELDS: Array<keyof BranchProtection> = [
275 "pattern",
276 "requirePullRequest",
277 "requireGreenGates",
278 "requireAiApproval",
279 "requireHumanReview",
280 "requiredApprovals",
281 "allowForcePush",
282 "allowDeletion",
283 "dismissStaleReviews",
284 "enableAutoMerge",
285];
286
287export function renderDiff(
288 before: BranchProtection | null,
289 after: BranchProtection
290): string {
291 const lines: string[] = [];
292 if (!before) {
293 lines.push("(no previous branch_protection row)");
294 lines.push("AFTER:");
295 for (const f of TRACKED_FIELDS) {
296 lines.push(` + ${f} = ${JSON.stringify((after as any)[f])}`);
297 }
298 return lines.join("\n");
299 }
300 lines.push("BEFORE -> AFTER (only changed fields shown):");
301 let anyChanged = false;
302 for (const f of TRACKED_FIELDS) {
303 const b = (before as any)[f];
304 const a = (after as any)[f];
305 if (JSON.stringify(b) !== JSON.stringify(a)) {
306 anyChanged = true;
307 lines.push(` ~ ${f}: ${JSON.stringify(b)} -> ${JSON.stringify(a)}`);
308 }
309 }
310 if (!anyChanged) lines.push(" (no field changes)");
311 return lines.join("\n");
312}
313
314// ---------------------------------------------------------------------------
315// CLI entry
316// ---------------------------------------------------------------------------
317
318function usage(): never {
319 console.error(
320 "Usage: bun run scripts/enable-auto-merge.ts <owner/name> [pattern] [--off]"
321 );
322 console.error(
323 " pattern defaults to 'main'. Pass --off to flip the switch back to disabled."
324 );
325 process.exit(2);
326}
327
328async function main() {
329 const argv = process.argv.slice(2);
330 if (argv.length === 0) usage();
331
332 let off = false;
333 const positional: string[] = [];
334 for (const a of argv) {
335 if (a === "--off") {
336 off = true;
337 } else if (a.startsWith("--")) {
338 console.error(`Unknown flag: ${a}`);
339 usage();
340 } else {
341 positional.push(a);
342 }
343 }
344 const [ownerSlash, patternArg] = positional;
345 if (!ownerSlash) usage();
346 const pattern = patternArg ?? "main";
347
348 // Lazy import the real DB only at CLI-entry time so unit tests can
349 // import this module without booting a Neon connection.
350 const { db } = await import("../src/db");
351 const { audit } = await import("../src/lib/notify");
352
353 const result = await runEnableAutoMerge(
354 db as unknown as DbLike,
355 { ownerSlash, pattern, off },
356 audit
357 );
358
359 console.log(`gluecron enable-auto-merge — ${ownerSlash} @ ${pattern}`);
360 console.log(`action: ${result.action}`);
361 console.log(renderDiff(result.before, result.after));
362 if (result.auditWritten) {
363 console.log(
364 `audit: wrote ${off ? "auto_merge.disabled_on_main" : "auto_merge.enabled_on_main"}`
365 );
366 } else {
367 console.log("audit: skipped (no-op, row already in desired state)");
368 }
369 if (result.action === "inserted") {
370 console.log(
371 "note: created a fresh branch_protection row with the safety defaults."
372 );
373 }
374}
375
376// Only run when invoked as a script (not when imported by tests).
377if (import.meta.main) {
378 main().catch((err) => {
379 console.error(err instanceof Error ? err.message : String(err));
380 process.exit(1);
381 });
382}
383
384// Silence unused-import warning when this module is only used as a CLI.
385void auditLog;