Blame · Line-by-line history
automation-settings.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.
| 479dcd9 | 1 | /** |
| 2 | * Per-repo automation settings — the single read/write surface for the | |
| 3 | * "Automation" settings page (`/:owner/:repo/settings/automation`) and for | |
| 4 | * every automation dispatch site (AI review, PR/issue triage, auto-merge, | |
| 5 | * CI autofix). | |
| 6 | * | |
| 7 | * Contract: | |
| 8 | * | |
| 9 | * - One `repo_automation_settings` row per repository (migration 0106). | |
| 10 | * No row → `AUTOMATION_DEFAULTS`, which match pre-0106 behavior exactly, | |
| 11 | * so a repo that never touches the page behaves as it always did. | |
| 12 | * - `getAutomationSettings` FAILS OPEN: any DB error returns the defaults | |
| 13 | * so a broken lookup can never change platform behavior. | |
| 14 | * - Env kill-switches stay supreme. A feature disabled at the environment | |
| 15 | * level (missing ANTHROPIC_API_KEY, AI_LOOP_ENABLED unset, …) is off | |
| 16 | * regardless of the stored mode — see `resolveEffectiveMode`. Dispatch | |
| 17 | * sites keep their existing env guards; this module only ever *narrows* | |
| 18 | * what runs, never widens it. | |
| 19 | * | |
| 20 | * Mode semantics per feature (see drizzle/0106 for the long-form docs): | |
| 21 | * | |
| 22 | * aiReviewMode 'off' | 'suggest' (default 'suggest') | |
| 23 | * prTriageMode 'off' | 'suggest' (default 'suggest') | |
| 24 | * issueTriageMode 'off' | 'suggest' (default 'suggest') | |
| 64aa989 | 25 | * autoMergeMode 'off' | 'suggest' | 'auto' (default 'off') |
| 479dcd9 | 26 | * ciAutofixMode 'off' | 'suggest' | 'auto' (default 'suggest') |
| 64aa989 | 27 | * autoRepairMode 'off' | 'suggest' (default 'suggest') |
| 479dcd9 | 28 | * |
| 29 | * Where only on/off is meaningful, 'auto' is treated the same as 'suggest' | |
| 30 | * (i.e. "on") — `isAutomationOn` encodes that rule. | |
| 31 | */ | |
| 32 | ||
| 33 | import { eq } from "drizzle-orm"; | |
| 34 | import { db } from "../db"; | |
| 35 | import { repoAutomationSettings } from "../db/schema"; | |
| 36 | ||
| 37 | // --------------------------------------------------------------------------- | |
| 38 | // Types + defaults | |
| 39 | // --------------------------------------------------------------------------- | |
| 40 | ||
| 41 | export type AutomationMode = "off" | "suggest" | "auto"; | |
| 42 | ||
| 43 | export interface AutomationSettings { | |
| 44 | aiReviewMode: AutomationMode; | |
| 45 | prTriageMode: AutomationMode; | |
| 46 | issueTriageMode: AutomationMode; | |
| 47 | autoMergeMode: AutomationMode; | |
| 48 | ciAutofixMode: AutomationMode; | |
| 64aa989 | 49 | autoRepairMode: AutomationMode; |
| 479dcd9 | 50 | } |
| 51 | ||
| 52 | /** Loader signature — the DI seam dispatch sites accept for tests. */ | |
| 53 | export type AutomationSettingsLoader = ( | |
| 54 | repositoryId: string | |
| 55 | ) => Promise<AutomationSettings>; | |
| 56 | ||
| 57 | /** | |
| 58 | * Defaults = pre-0106 platform behavior. Auto-merge defaults to 'auto' | |
| 59 | * because the K2/K3 path already merges automatically (still default-deny | |
| 60 | * per branch via branch_protection.enable_auto_merge); everything else | |
| 61 | * defaults to 'suggest' because those features only ever posted advisory | |
| 62 | * comments. | |
| 63 | */ | |
| 64 | export const AUTOMATION_DEFAULTS: Readonly<AutomationSettings> = Object.freeze({ | |
| 65 | aiReviewMode: "suggest", | |
| 66 | prTriageMode: "suggest", | |
| 67 | issueTriageMode: "suggest", | |
| 64aa989 | 68 | autoMergeMode: "off", |
| 479dcd9 | 69 | ciAutofixMode: "suggest", |
| 64aa989 | 70 | autoRepairMode: "suggest", |
| 479dcd9 | 71 | }); |
| 72 | ||
| 73 | // --------------------------------------------------------------------------- | |
| 74 | // Pure mode-resolution helpers (unit-testable, no DB) | |
| 75 | // --------------------------------------------------------------------------- | |
| 76 | ||
| 77 | /** Parse an untrusted value (form input, stale DB row) into a mode. */ | |
| 78 | export function normalizeMode( | |
| 79 | value: unknown, | |
| 80 | fallback: AutomationMode | |
| 81 | ): AutomationMode { | |
| 82 | return value === "off" || value === "suggest" || value === "auto" | |
| 83 | ? value | |
| 84 | : fallback; | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * Env-supremacy rule: when the environment kill-switch for a feature is | |
| 89 | * off, the effective mode is 'off' no matter what the repo row says. The | |
| 90 | * repo setting can only narrow further (e.g. env on + repo 'off' → 'off'). | |
| 91 | */ | |
| 92 | export function resolveEffectiveMode( | |
| 93 | repoMode: AutomationMode, | |
| 94 | envEnabled: boolean | |
| 95 | ): AutomationMode { | |
| 96 | if (!envEnabled) return "off"; | |
| 97 | return repoMode; | |
| 98 | } | |
| 99 | ||
| 100 | /** "Is this feature on at all?" — 'suggest' and 'auto' both count as on. */ | |
| 101 | export function isAutomationOn(mode: AutomationMode): boolean { | |
| 102 | return mode !== "off"; | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * Coerce a raw row (or anything row-shaped) into a fully-valid settings | |
| 107 | * object, falling back per-field to the defaults. Exported for tests. | |
| 108 | */ | |
| 109 | export function settingsFromRow( | |
| 110 | row: Partial<Record<keyof AutomationSettings, unknown>> | null | undefined | |
| 111 | ): AutomationSettings { | |
| 112 | if (!row) return { ...AUTOMATION_DEFAULTS }; | |
| 113 | return { | |
| 114 | aiReviewMode: normalizeMode(row.aiReviewMode, AUTOMATION_DEFAULTS.aiReviewMode), | |
| 115 | prTriageMode: normalizeMode(row.prTriageMode, AUTOMATION_DEFAULTS.prTriageMode), | |
| 116 | issueTriageMode: normalizeMode( | |
| 117 | row.issueTriageMode, | |
| 118 | AUTOMATION_DEFAULTS.issueTriageMode | |
| 119 | ), | |
| 120 | autoMergeMode: normalizeMode(row.autoMergeMode, AUTOMATION_DEFAULTS.autoMergeMode), | |
| 121 | ciAutofixMode: normalizeMode(row.ciAutofixMode, AUTOMATION_DEFAULTS.ciAutofixMode), | |
| 64aa989 | 122 | autoRepairMode: normalizeMode(row.autoRepairMode, AUTOMATION_DEFAULTS.autoRepairMode), |
| 479dcd9 | 123 | }; |
| 124 | } | |
| 125 | ||
| 126 | // --------------------------------------------------------------------------- | |
| 127 | // DB-backed loader + upsert | |
| 128 | // --------------------------------------------------------------------------- | |
| 129 | ||
| 130 | /** | |
| 131 | * Load the automation settings for a repository. No row → defaults. | |
| 132 | * FAILS OPEN: any DB error also returns the defaults (logged once at warn) | |
| 133 | * so a broken settings lookup never alters current platform behavior. | |
| 134 | */ | |
| 135 | export async function getAutomationSettings( | |
| 136 | repositoryId: string | |
| 137 | ): Promise<AutomationSettings> { | |
| 138 | try { | |
| 139 | const [row] = await db | |
| 140 | .select() | |
| 141 | .from(repoAutomationSettings) | |
| 142 | .where(eq(repoAutomationSettings.repositoryId, repositoryId)) | |
| 143 | .limit(1); | |
| 144 | return settingsFromRow(row ?? null); | |
| 145 | } catch (err) { | |
| 146 | console.warn( | |
| 147 | "[automation-settings] lookup failed (falling back to defaults):", | |
| 148 | err instanceof Error ? err.message : err | |
| 149 | ); | |
| 150 | return { ...AUTOMATION_DEFAULTS }; | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | /** | |
| 155 | * Create or update the settings row for a repository. Partial patches are | |
| 156 | * merged over the current effective settings so a form that only posts one | |
| 157 | * field can't reset the others. Throws on DB failure — callers (the | |
| 158 | * settings route) surface the error to the user. | |
| 159 | */ | |
| 160 | export async function upsertAutomationSettings( | |
| 161 | repositoryId: string, | |
| 162 | patch: Partial<AutomationSettings> | |
| 163 | ): Promise<AutomationSettings> { | |
| 164 | const current = await getAutomationSettings(repositoryId); | |
| 165 | const next: AutomationSettings = { | |
| 166 | aiReviewMode: normalizeMode(patch.aiReviewMode, current.aiReviewMode), | |
| 167 | prTriageMode: normalizeMode(patch.prTriageMode, current.prTriageMode), | |
| 168 | issueTriageMode: normalizeMode(patch.issueTriageMode, current.issueTriageMode), | |
| 169 | autoMergeMode: normalizeMode(patch.autoMergeMode, current.autoMergeMode), | |
| 170 | ciAutofixMode: normalizeMode(patch.ciAutofixMode, current.ciAutofixMode), | |
| 64aa989 | 171 | autoRepairMode: normalizeMode(patch.autoRepairMode, current.autoRepairMode), |
| 479dcd9 | 172 | }; |
| 173 | ||
| 174 | await db | |
| 175 | .insert(repoAutomationSettings) | |
| 176 | .values({ repositoryId, ...next }) | |
| 177 | .onConflictDoUpdate({ | |
| 178 | target: repoAutomationSettings.repositoryId, | |
| 179 | set: { ...next, updatedAt: new Date() }, | |
| 180 | }); | |
| 181 | ||
| 182 | return next; | |
| 183 | } |