CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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') | |
| 25 | * autoMergeMode 'off' | 'suggest' | 'auto' (default 'auto') | |
| 26 | * ciAutofixMode 'off' | 'suggest' | 'auto' (default 'suggest') | |
| 27 | * | |
| 28 | * Where only on/off is meaningful, 'auto' is treated the same as 'suggest' | |
| 29 | * (i.e. "on") — `isAutomationOn` encodes that rule. | |
| 30 | */ | |
| 31 | ||
| 32 | import { eq } from "drizzle-orm"; | |
| 33 | import { db } from "../db"; | |
| 34 | import { repoAutomationSettings } from "../db/schema"; | |
| 35 | ||
| 36 | // --------------------------------------------------------------------------- | |
| 37 | // Types + defaults | |
| 38 | // --------------------------------------------------------------------------- | |
| 39 | ||
| 40 | export type AutomationMode = "off" | "suggest" | "auto"; | |
| 41 | ||
| 42 | export interface AutomationSettings { | |
| 43 | aiReviewMode: AutomationMode; | |
| 44 | prTriageMode: AutomationMode; | |
| 45 | issueTriageMode: AutomationMode; | |
| 46 | autoMergeMode: AutomationMode; | |
| 47 | ciAutofixMode: AutomationMode; | |
| 48 | } | |
| 49 | ||
| 50 | /** Loader signature — the DI seam dispatch sites accept for tests. */ | |
| 51 | export type AutomationSettingsLoader = ( | |
| 52 | repositoryId: string | |
| 53 | ) => Promise<AutomationSettings>; | |
| 54 | ||
| 55 | /** | |
| 56 | * Defaults = pre-0106 platform behavior. Auto-merge defaults to 'auto' | |
| 57 | * because the K2/K3 path already merges automatically (still default-deny | |
| 58 | * per branch via branch_protection.enable_auto_merge); everything else | |
| 59 | * defaults to 'suggest' because those features only ever posted advisory | |
| 60 | * comments. | |
| 61 | */ | |
| 62 | export const AUTOMATION_DEFAULTS: Readonly<AutomationSettings> = Object.freeze({ | |
| 63 | aiReviewMode: "suggest", | |
| 64 | prTriageMode: "suggest", | |
| 65 | issueTriageMode: "suggest", | |
| 66 | autoMergeMode: "auto", | |
| 67 | ciAutofixMode: "suggest", | |
| 68 | }); | |
| 69 | ||
| 70 | // --------------------------------------------------------------------------- | |
| 71 | // Pure mode-resolution helpers (unit-testable, no DB) | |
| 72 | // --------------------------------------------------------------------------- | |
| 73 | ||
| 74 | /** Parse an untrusted value (form input, stale DB row) into a mode. */ | |
| 75 | export function normalizeMode( | |
| 76 | value: unknown, | |
| 77 | fallback: AutomationMode | |
| 78 | ): AutomationMode { | |
| 79 | return value === "off" || value === "suggest" || value === "auto" | |
| 80 | ? value | |
| 81 | : fallback; | |
| 82 | } | |
| 83 | ||
| 84 | /** | |
| 85 | * Env-supremacy rule: when the environment kill-switch for a feature is | |
| 86 | * off, the effective mode is 'off' no matter what the repo row says. The | |
| 87 | * repo setting can only narrow further (e.g. env on + repo 'off' → 'off'). | |
| 88 | */ | |
| 89 | export function resolveEffectiveMode( | |
| 90 | repoMode: AutomationMode, | |
| 91 | envEnabled: boolean | |
| 92 | ): AutomationMode { | |
| 93 | if (!envEnabled) return "off"; | |
| 94 | return repoMode; | |
| 95 | } | |
| 96 | ||
| 97 | /** "Is this feature on at all?" — 'suggest' and 'auto' both count as on. */ | |
| 98 | export function isAutomationOn(mode: AutomationMode): boolean { | |
| 99 | return mode !== "off"; | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Coerce a raw row (or anything row-shaped) into a fully-valid settings | |
| 104 | * object, falling back per-field to the defaults. Exported for tests. | |
| 105 | */ | |
| 106 | export function settingsFromRow( | |
| 107 | row: Partial<Record<keyof AutomationSettings, unknown>> | null | undefined | |
| 108 | ): AutomationSettings { | |
| 109 | if (!row) return { ...AUTOMATION_DEFAULTS }; | |
| 110 | return { | |
| 111 | aiReviewMode: normalizeMode(row.aiReviewMode, AUTOMATION_DEFAULTS.aiReviewMode), | |
| 112 | prTriageMode: normalizeMode(row.prTriageMode, AUTOMATION_DEFAULTS.prTriageMode), | |
| 113 | issueTriageMode: normalizeMode( | |
| 114 | row.issueTriageMode, | |
| 115 | AUTOMATION_DEFAULTS.issueTriageMode | |
| 116 | ), | |
| 117 | autoMergeMode: normalizeMode(row.autoMergeMode, AUTOMATION_DEFAULTS.autoMergeMode), | |
| 118 | ciAutofixMode: normalizeMode(row.ciAutofixMode, AUTOMATION_DEFAULTS.ciAutofixMode), | |
| 119 | }; | |
| 120 | } | |
| 121 | ||
| 122 | // --------------------------------------------------------------------------- | |
| 123 | // DB-backed loader + upsert | |
| 124 | // --------------------------------------------------------------------------- | |
| 125 | ||
| 126 | /** | |
| 127 | * Load the automation settings for a repository. No row → defaults. | |
| 128 | * FAILS OPEN: any DB error also returns the defaults (logged once at warn) | |
| 129 | * so a broken settings lookup never alters current platform behavior. | |
| 130 | */ | |
| 131 | export async function getAutomationSettings( | |
| 132 | repositoryId: string | |
| 133 | ): Promise<AutomationSettings> { | |
| 134 | try { | |
| 135 | const [row] = await db | |
| 136 | .select() | |
| 137 | .from(repoAutomationSettings) | |
| 138 | .where(eq(repoAutomationSettings.repositoryId, repositoryId)) | |
| 139 | .limit(1); | |
| 140 | return settingsFromRow(row ?? null); | |
| 141 | } catch (err) { | |
| 142 | console.warn( | |
| 143 | "[automation-settings] lookup failed (falling back to defaults):", | |
| 144 | err instanceof Error ? err.message : err | |
| 145 | ); | |
| 146 | return { ...AUTOMATION_DEFAULTS }; | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | /** | |
| 151 | * Create or update the settings row for a repository. Partial patches are | |
| 152 | * merged over the current effective settings so a form that only posts one | |
| 153 | * field can't reset the others. Throws on DB failure — callers (the | |
| 154 | * settings route) surface the error to the user. | |
| 155 | */ | |
| 156 | export async function upsertAutomationSettings( | |
| 157 | repositoryId: string, | |
| 158 | patch: Partial<AutomationSettings> | |
| 159 | ): Promise<AutomationSettings> { | |
| 160 | const current = await getAutomationSettings(repositoryId); | |
| 161 | const next: AutomationSettings = { | |
| 162 | aiReviewMode: normalizeMode(patch.aiReviewMode, current.aiReviewMode), | |
| 163 | prTriageMode: normalizeMode(patch.prTriageMode, current.prTriageMode), | |
| 164 | issueTriageMode: normalizeMode(patch.issueTriageMode, current.issueTriageMode), | |
| 165 | autoMergeMode: normalizeMode(patch.autoMergeMode, current.autoMergeMode), | |
| 166 | ciAutofixMode: normalizeMode(patch.ciAutofixMode, current.ciAutofixMode), | |
| 167 | }; | |
| 168 | ||
| 169 | await db | |
| 170 | .insert(repoAutomationSettings) | |
| 171 | .values({ repositoryId, ...next }) | |
| 172 | .onConflictDoUpdate({ | |
| 173 | target: repoAutomationSettings.repositoryId, | |
| 174 | set: { ...next, updatedAt: new Date() }, | |
| 175 | }); | |
| 176 | ||
| 177 | return next; | |
| 178 | } |