CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
environments.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.
| 25a91a6 | 1 | /** |
| 2 | * Block C4 — Environment + deployment-approval helpers. | |
| 3 | * | |
| 4 | * v1 semantics: | |
| 5 | * - approval = any single reviewer approves (required = 1). | |
| 6 | * - rejection by any reviewer hard-stops the deploy. | |
| 7 | * - if `reviewers` is empty, the repo owner is treated as the implicit reviewer. | |
| 8 | * - `allowedBranches` is a JSON array of glob patterns. When non-empty only | |
| 9 | * refs matching at least one pattern may deploy through the environment. | |
| a76d984 | 10 | * - `waitTimerMinutes` is now enforced: approval flips status to |
| 11 | * "waiting_timer" + populates `deployments.ready_after`; the autopilot | |
| 12 | * ticker sweeps timers that have elapsed and flips them to "pending". | |
| 25a91a6 | 13 | * |
| 14 | * All DB calls are wrapped in try/catch so the caller gets well-defined | |
| 15 | * shapes even when the database is unreachable (keeps the hot push path safe). | |
| 16 | */ | |
| 17 | ||
| a76d984 | 18 | import { and, desc, eq, isNotNull, lte, sql } from "drizzle-orm"; |
| 25a91a6 | 19 | import { db } from "../db"; |
| 20 | import { | |
| 21 | environments, | |
| 22 | deploymentApprovals, | |
| a76d984 | 23 | deployments, |
| 25a91a6 | 24 | repositories, |
| 25 | } from "../db/schema"; | |
| 26 | import type { Environment, DeploymentApproval } from "../db/schema"; | |
| 27 | ||
| 28 | // --------------------------------------------------------------------------- | |
| 29 | // Glob matching (minimal — `*` segment, `**` any path, literals) | |
| 30 | // --------------------------------------------------------------------------- | |
| 31 | ||
| 32 | /** Normalise a ref for matching — strip `refs/heads/`, `refs/tags/`. */ | |
| 33 | function normaliseRef(ref: string): string { | |
| 34 | if (ref.startsWith("refs/heads/")) return ref.slice("refs/heads/".length); | |
| 35 | if (ref.startsWith("refs/tags/")) return ref.slice("refs/tags/".length); | |
| 36 | return ref; | |
| 37 | } | |
| 38 | ||
| 39 | /** Minimal glob → RegExp. `*` = one path segment, `**` = any. */ | |
| 40 | export function matchGlob(value: string, pattern: string): boolean { | |
| 41 | const v = normaliseRef(value); | |
| 42 | const p = normaliseRef(pattern); | |
| 43 | if (v === p) return true; | |
| 44 | // Escape regex metachars, then re-expand `**` and `*`. | |
| 45 | const re = p | |
| 46 | .replace(/[.+^${}()|[\]\\]/g, "\\$&") | |
| 47 | .replace(/\*\*/g, "::DOUBLESTAR::") | |
| 48 | .replace(/\*/g, "[^/]*") | |
| 49 | .replace(/::DOUBLESTAR::/g, ".*"); | |
| 50 | return new RegExp(`^${re}$`).test(v); | |
| 51 | } | |
| 52 | ||
| 53 | function matchesAny(value: string, patterns: string[]): boolean { | |
| 54 | if (patterns.length === 0) return true; | |
| 55 | return patterns.some((p) => matchGlob(value, p)); | |
| 56 | } | |
| 57 | ||
| 58 | // --------------------------------------------------------------------------- | |
| 59 | // Environments CRUD | |
| 60 | // --------------------------------------------------------------------------- | |
| 61 | ||
| 62 | export async function listEnvironments( | |
| 63 | repositoryId: string | |
| 64 | ): Promise<Environment[]> { | |
| 65 | try { | |
| 66 | return await db | |
| 67 | .select() | |
| 68 | .from(environments) | |
| 69 | .where(eq(environments.repositoryId, repositoryId)) | |
| 70 | .orderBy(desc(environments.createdAt)); | |
| 71 | } catch (err) { | |
| 72 | console.error("[environments] list failed:", err); | |
| 73 | return []; | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | export async function getEnvironmentById( | |
| 78 | repositoryId: string, | |
| 79 | id: string | |
| 80 | ): Promise<Environment | null> { | |
| 81 | try { | |
| 82 | const [row] = await db | |
| 83 | .select() | |
| 84 | .from(environments) | |
| 85 | .where( | |
| 86 | and(eq(environments.id, id), eq(environments.repositoryId, repositoryId)) | |
| 87 | ) | |
| 88 | .limit(1); | |
| 89 | return row || null; | |
| 90 | } catch (err) { | |
| 91 | console.error("[environments] getById failed:", err); | |
| 92 | return null; | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | export async function getEnvironmentByName( | |
| 97 | repositoryId: string, | |
| 98 | name: string | |
| 99 | ): Promise<Environment | null> { | |
| 100 | try { | |
| 101 | const [row] = await db | |
| 102 | .select() | |
| 103 | .from(environments) | |
| 104 | .where( | |
| 105 | and( | |
| 106 | eq(environments.repositoryId, repositoryId), | |
| 107 | eq(environments.name, name) | |
| 108 | ) | |
| 109 | ) | |
| 110 | .limit(1); | |
| 111 | return row || null; | |
| 112 | } catch (err) { | |
| 113 | console.error("[environments] getByName failed:", err); | |
| 114 | return null; | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | export async function getOrCreateEnvironment( | |
| 119 | repositoryId: string, | |
| 120 | name: string | |
| 121 | ): Promise<Environment> { | |
| 122 | const existing = await getEnvironmentByName(repositoryId, name); | |
| 123 | if (existing) return existing; | |
| 124 | try { | |
| 125 | const [inserted] = await db | |
| 126 | .insert(environments) | |
| 127 | .values({ repositoryId, name }) | |
| 128 | .returning(); | |
| 129 | if (inserted) return inserted; | |
| 130 | } catch (err) { | |
| 131 | // Unique-index collision from a concurrent insert — fall through to re-read. | |
| 132 | console.error("[environments] create failed:", err); | |
| 133 | } | |
| 134 | const reread = await getEnvironmentByName(repositoryId, name); | |
| 135 | if (reread) return reread; | |
| 136 | // Absolute fallback — synthesize an in-memory shell so callers never crash. | |
| 137 | // This path is only reached if the DB is unreachable for both insert + read. | |
| 138 | return { | |
| 139 | id: "", | |
| 140 | repositoryId, | |
| 141 | name, | |
| 142 | requireApproval: false, | |
| 143 | reviewers: "[]", | |
| 144 | waitTimerMinutes: 0, | |
| 145 | allowedBranches: "[]", | |
| 146 | createdAt: new Date(), | |
| 147 | updatedAt: new Date(), | |
| 148 | } as Environment; | |
| 149 | } | |
| 150 | ||
| 151 | // --------------------------------------------------------------------------- | |
| 152 | // Reviewer semantics | |
| 153 | // --------------------------------------------------------------------------- | |
| 154 | ||
| 155 | function parseJsonArray(raw: string | null | undefined): string[] { | |
| 156 | if (!raw) return []; | |
| 157 | try { | |
| 158 | const v = JSON.parse(raw); | |
| 159 | return Array.isArray(v) ? v.map(String) : []; | |
| 160 | } catch { | |
| 161 | return []; | |
| 162 | } | |
| 163 | } | |
| 164 | ||
| 165 | export function reviewerIdsOf(env: Environment): string[] { | |
| 166 | return parseJsonArray(env.reviewers); | |
| 167 | } | |
| 168 | ||
| 169 | export function allowedBranchesOf(env: Environment): string[] { | |
| 170 | return parseJsonArray(env.allowedBranches); | |
| 171 | } | |
| 172 | ||
| 173 | /** | |
| 174 | * Return true if `userId` is allowed to approve/reject deploys for this env. | |
| 175 | * If the reviewer list is empty, fall back to the repo owner. | |
| 176 | */ | |
| 177 | export async function isReviewer( | |
| 178 | env: Environment, | |
| 179 | userId: string | |
| 180 | ): Promise<boolean> { | |
| 181 | const reviewers = reviewerIdsOf(env); | |
| 182 | if (reviewers.includes(userId)) return true; | |
| 183 | if (reviewers.length === 0) { | |
| 184 | try { | |
| 185 | const [row] = await db | |
| 186 | .select({ ownerId: repositories.ownerId }) | |
| 187 | .from(repositories) | |
| 188 | .where(eq(repositories.id, env.repositoryId)) | |
| 189 | .limit(1); | |
| 190 | return row?.ownerId === userId; | |
| 191 | } catch (err) { | |
| 192 | console.error("[environments] isReviewer owner lookup failed:", err); | |
| 193 | return false; | |
| 194 | } | |
| 195 | } | |
| 196 | return false; | |
| 197 | } | |
| 198 | ||
| 199 | // --------------------------------------------------------------------------- | |
| 200 | // Approvals | |
| 201 | // --------------------------------------------------------------------------- | |
| 202 | ||
| 203 | export async function listApprovals( | |
| 204 | deploymentId: string | |
| 205 | ): Promise<DeploymentApproval[]> { | |
| 206 | try { | |
| 207 | return await db | |
| 208 | .select() | |
| 209 | .from(deploymentApprovals) | |
| 210 | .where(eq(deploymentApprovals.deploymentId, deploymentId)) | |
| 211 | .orderBy(desc(deploymentApprovals.createdAt)); | |
| 212 | } catch (err) { | |
| 213 | console.error("[environments] listApprovals failed:", err); | |
| 214 | return []; | |
| 215 | } | |
| 216 | } | |
| 217 | ||
| 218 | /** | |
| 219 | * Pure reducer — given a list of decisions, compute approved/rejected flags. | |
| 220 | * Exported so tests can exercise it without a DB. | |
| 221 | */ | |
| 222 | export function reduceApprovalState(decided: DeploymentApproval[]): { | |
| 223 | approved: boolean; | |
| 224 | rejected: boolean; | |
| 225 | decided: DeploymentApproval[]; | |
| 226 | } { | |
| 227 | const rejected = decided.some((d) => d.decision === "rejected"); | |
| 228 | const approved = !rejected && decided.some((d) => d.decision === "approved"); | |
| 229 | return { approved, rejected, decided }; | |
| 230 | } | |
| 231 | ||
| a76d984 | 232 | /** |
| 233 | * Pure helper — return the timestamp of the most recent "approved" | |
| 234 | * decision, or null if there are no approvals. Used as the basis for | |
| 235 | * wait-timer enforcement. | |
| 236 | */ | |
| 237 | export function latestApprovalAt( | |
| 238 | decided: DeploymentApproval[] | |
| 239 | ): Date | null { | |
| 240 | let latest: Date | null = null; | |
| 241 | for (const d of decided) { | |
| 242 | if (d.decision !== "approved") continue; | |
| 243 | const t = d.createdAt ? new Date(d.createdAt) : null; | |
| 244 | if (!t || isNaN(t.getTime())) continue; | |
| 245 | if (!latest || t.getTime() > latest.getTime()) latest = t; | |
| 246 | } | |
| 247 | return latest; | |
| 248 | } | |
| 249 | ||
| 250 | /** | |
| 251 | * Pure helper — given an environment + the current approvals + a "now" | |
| 252 | * reference, return the Date when the deploy may proceed, or null if | |
| 253 | * either the env has no wait timer (waitTimerMinutes <= 0) or there are | |
| 254 | * no approvals yet. Returning a past timestamp is deliberate — caller | |
| 255 | * can compare `readyAfter <= now` to decide whether to skip the | |
| 256 | * "waiting_timer" state entirely. | |
| 257 | */ | |
| 258 | export function computeReadyAfter( | |
| 259 | env: Pick<Environment, "waitTimerMinutes">, | |
| 260 | decided: DeploymentApproval[] | |
| 261 | ): Date | null { | |
| 262 | const minutes = Number(env.waitTimerMinutes || 0); | |
| 263 | if (!Number.isFinite(minutes) || minutes <= 0) return null; | |
| 264 | const last = latestApprovalAt(decided); | |
| 265 | if (!last) return null; | |
| 266 | return new Date(last.getTime() + minutes * 60_000); | |
| 267 | } | |
| 268 | ||
| 25a91a6 | 269 | /** |
| 270 | * v1 semantics: approved = at least one approval exists and no rejection. | |
| 271 | * rejected = at least one rejection exists. | |
| a76d984 | 272 | * |
| 273 | * waitTimerMinutes enforcement: when the env carries a positive timer | |
| 274 | * the response includes `readyAfter` — the wall-clock the deployer | |
| 275 | * should not run before. Callers compare against `now`: | |
| 276 | * - readyAfter == null → proceed immediately on approval | |
| 277 | * - readyAfter <= now → proceed (timer already elapsed) | |
| 278 | * - readyAfter > now → set deployment.status="waiting_timer" and | |
| 279 | * deployment.readyAfter=readyAfter, then let | |
| 280 | * the autopilot ticker (`releaseExpiredWaitTimers`) | |
| 281 | * flip it to "pending" once the wall passes. | |
| 25a91a6 | 282 | */ |
| 283 | export async function computeApprovalState( | |
| 284 | deploymentId: string, | |
| a76d984 | 285 | env: Environment |
| 25a91a6 | 286 | ): Promise<{ |
| 287 | approved: boolean; | |
| 288 | rejected: boolean; | |
| 289 | decided: DeploymentApproval[]; | |
| a76d984 | 290 | readyAfter: Date | null; |
| 25a91a6 | 291 | }> { |
| 292 | const decided = await listApprovals(deploymentId); | |
| a76d984 | 293 | const base = reduceApprovalState(decided); |
| 294 | const readyAfter = base.approved ? computeReadyAfter(env, decided) : null; | |
| 295 | return { ...base, readyAfter }; | |
| 25a91a6 | 296 | } |
| 297 | ||
| 298 | /** | |
| 299 | * Record a reviewer's decision. Returns the inserted row, or null on any | |
| 300 | * failure (duplicate, DB unreachable, etc). | |
| 301 | */ | |
| 302 | export async function recordApproval(opts: { | |
| 303 | deploymentId: string; | |
| 304 | userId: string; | |
| 305 | decision: "approved" | "rejected"; | |
| 306 | comment?: string; | |
| 307 | }): Promise<DeploymentApproval | null> { | |
| 308 | try { | |
| 309 | const [row] = await db | |
| 310 | .insert(deploymentApprovals) | |
| 311 | .values({ | |
| 312 | deploymentId: opts.deploymentId, | |
| 313 | userId: opts.userId, | |
| 314 | decision: opts.decision, | |
| 315 | comment: opts.comment ?? null, | |
| 316 | }) | |
| 317 | .returning(); | |
| 318 | return row || null; | |
| 319 | } catch (err) { | |
| 320 | console.error("[environments] recordApproval failed:", err); | |
| 321 | return null; | |
| 322 | } | |
| 323 | } | |
| 324 | ||
| 325 | // --------------------------------------------------------------------------- | |
| 326 | // Push-time gate (called by post-receive before the deploy executes) | |
| 327 | // --------------------------------------------------------------------------- | |
| 328 | ||
| 329 | /** | |
| 330 | * Pure read — returns whether a deploy to (repo, envName, ref) needs approval. | |
| 331 | * | |
| 332 | * { required: true, env } → caller should create the deployment row with | |
| 333 | * status="pending_approval" (or "blocked" if | |
| 334 | * env?.blockedReason would apply — that's the | |
| 335 | * caller's call; we only flag required=true). | |
| 336 | * { required: false, env } → caller may proceed to status="pending". | |
| 337 | * | |
| 338 | * Branch-glob enforcement: if the env's `allowedBranches` is non-empty and the | |
| 339 | * ref does not match any pattern, we still return `required: true` so the | |
| 340 | * caller knows to block. The caller can read `allowedBranchesOf(env)` to set | |
| 341 | * `blockedReason: "branch not allowed for environment"` on the deployment row. | |
| 342 | */ | |
| 343 | export async function requiresApprovalFor( | |
| 344 | repositoryId: string, | |
| 345 | envName: string, | |
| 346 | ref: string | |
| 347 | ): Promise<{ required: boolean; env: Environment | null }> { | |
| 348 | const env = await getEnvironmentByName(repositoryId, envName); | |
| 349 | if (!env) return { required: false, env: null }; | |
| 350 | ||
| 351 | const allowed = allowedBranchesOf(env); | |
| 352 | if (allowed.length > 0 && !matchesAny(ref, allowed)) { | |
| 353 | return { required: true, env }; | |
| 354 | } | |
| 355 | if (env.requireApproval) return { required: true, env }; | |
| 356 | return { required: false, env }; | |
| 357 | } | |
| a76d984 | 358 | |
| 359 | // --------------------------------------------------------------------------- | |
| 360 | // Wait-timer sweeper | |
| 361 | // --------------------------------------------------------------------------- | |
| 362 | ||
| 363 | /** | |
| 364 | * Flip every "waiting_timer" deployment whose `ready_after` has passed to | |
| 365 | * "pending" so the deployer picks it up. Called from the autopilot ticker. | |
| 366 | * | |
| 367 | * Returns the number of deployments released. Best-effort — DB hiccups | |
| 368 | * resolve to `0` rather than throw, so the autopilot loop never wedges. | |
| 369 | */ | |
| 370 | export async function releaseExpiredWaitTimers( | |
| 371 | now: Date = new Date() | |
| 372 | ): Promise<number> { | |
| 373 | try { | |
| 374 | const rows = await db | |
| 375 | .update(deployments) | |
| 376 | .set({ status: "pending" }) | |
| 377 | .where( | |
| 378 | and( | |
| 379 | eq(deployments.status, "waiting_timer"), | |
| 380 | isNotNull(deployments.readyAfter), | |
| 381 | lte(deployments.readyAfter, sql`${now.toISOString()}::timestamptz`) | |
| 382 | ) | |
| 383 | ) | |
| 384 | .returning({ id: deployments.id }); | |
| 385 | return rows ? rows.length : 0; | |
| 386 | } catch (err) { | |
| 387 | console.error("[environments] releaseExpiredWaitTimers failed:", err); | |
| 388 | return 0; | |
| 389 | } | |
| 390 | } | |
| 391 | ||
| 392 | /** | |
| 393 | * Test-only: pure helpers + the sweeper exposed without DB. The sweeper is | |
| 394 | * already async-DB; we mainly want the pure helpers reachable for tests | |
| 395 | * that don't have access to a Postgres. | |
| 396 | */ | |
| 397 | export const __test = { | |
| 398 | latestApprovalAt, | |
| 399 | computeReadyAfter, | |
| 400 | }; |