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.
| f764c07 | 1 | /** |
| 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 | ||
| 42 | import { and, eq } from "drizzle-orm"; | |
| 43 | import { | |
| 44 | branchProtection, | |
| 45 | repositories, | |
| 46 | users, | |
| 47 | auditLog, | |
| 48 | } from "../src/db/schema"; | |
| 49 | import type { BranchProtection } from "../src/db/schema"; | |
| 50 | ||
| 51 | // --------------------------------------------------------------------------- | |
| 52 | // Types | |
| 53 | // --------------------------------------------------------------------------- | |
| 54 | ||
| 55 | export 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 | ||
| 62 | export 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 | */ | |
| 74 | export 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 | ||
| 84 | const 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 | */ | |
| 99 | export 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 | */ | |
| 142 | export 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 | ||
| 274 | const 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 | ||
| 287 | export 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 | ||
| 318 | function 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 | ||
| 328 | async 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). | |
| 377 | if (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. | |
| 385 | void auditLog; |