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. | |
| 10 | * - `waitTimerMinutes` is stored but NOT enforced in v1 (stub). | |
| 11 | * | |
| 12 | * All DB calls are wrapped in try/catch so the caller gets well-defined | |
| 13 | * shapes even when the database is unreachable (keeps the hot push path safe). | |
| 14 | */ | |
| 15 | ||
| 16 | import { and, desc, eq } from "drizzle-orm"; | |
| 17 | import { db } from "../db"; | |
| 18 | import { | |
| 19 | environments, | |
| 20 | deploymentApprovals, | |
| 21 | repositories, | |
| 22 | } from "../db/schema"; | |
| 23 | import type { Environment, DeploymentApproval } from "../db/schema"; | |
| 24 | ||
| 25 | // --------------------------------------------------------------------------- | |
| 26 | // Glob matching (minimal — `*` segment, `**` any path, literals) | |
| 27 | // --------------------------------------------------------------------------- | |
| 28 | ||
| 29 | /** Normalise a ref for matching — strip `refs/heads/`, `refs/tags/`. */ | |
| 30 | function normaliseRef(ref: string): string { | |
| 31 | if (ref.startsWith("refs/heads/")) return ref.slice("refs/heads/".length); | |
| 32 | if (ref.startsWith("refs/tags/")) return ref.slice("refs/tags/".length); | |
| 33 | return ref; | |
| 34 | } | |
| 35 | ||
| 36 | /** Minimal glob → RegExp. `*` = one path segment, `**` = any. */ | |
| 37 | export function matchGlob(value: string, pattern: string): boolean { | |
| 38 | const v = normaliseRef(value); | |
| 39 | const p = normaliseRef(pattern); | |
| 40 | if (v === p) return true; | |
| 41 | // Escape regex metachars, then re-expand `**` and `*`. | |
| 42 | const re = p | |
| 43 | .replace(/[.+^${}()|[\]\\]/g, "\\$&") | |
| 44 | .replace(/\*\*/g, "::DOUBLESTAR::") | |
| 45 | .replace(/\*/g, "[^/]*") | |
| 46 | .replace(/::DOUBLESTAR::/g, ".*"); | |
| 47 | return new RegExp(`^${re}$`).test(v); | |
| 48 | } | |
| 49 | ||
| 50 | function matchesAny(value: string, patterns: string[]): boolean { | |
| 51 | if (patterns.length === 0) return true; | |
| 52 | return patterns.some((p) => matchGlob(value, p)); | |
| 53 | } | |
| 54 | ||
| 55 | // --------------------------------------------------------------------------- | |
| 56 | // Environments CRUD | |
| 57 | // --------------------------------------------------------------------------- | |
| 58 | ||
| 59 | export async function listEnvironments( | |
| 60 | repositoryId: string | |
| 61 | ): Promise<Environment[]> { | |
| 62 | try { | |
| 63 | return await db | |
| 64 | .select() | |
| 65 | .from(environments) | |
| 66 | .where(eq(environments.repositoryId, repositoryId)) | |
| 67 | .orderBy(desc(environments.createdAt)); | |
| 68 | } catch (err) { | |
| 69 | console.error("[environments] list failed:", err); | |
| 70 | return []; | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | export async function getEnvironmentById( | |
| 75 | repositoryId: string, | |
| 76 | id: string | |
| 77 | ): Promise<Environment | null> { | |
| 78 | try { | |
| 79 | const [row] = await db | |
| 80 | .select() | |
| 81 | .from(environments) | |
| 82 | .where( | |
| 83 | and(eq(environments.id, id), eq(environments.repositoryId, repositoryId)) | |
| 84 | ) | |
| 85 | .limit(1); | |
| 86 | return row || null; | |
| 87 | } catch (err) { | |
| 88 | console.error("[environments] getById failed:", err); | |
| 89 | return null; | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | export async function getEnvironmentByName( | |
| 94 | repositoryId: string, | |
| 95 | name: string | |
| 96 | ): Promise<Environment | null> { | |
| 97 | try { | |
| 98 | const [row] = await db | |
| 99 | .select() | |
| 100 | .from(environments) | |
| 101 | .where( | |
| 102 | and( | |
| 103 | eq(environments.repositoryId, repositoryId), | |
| 104 | eq(environments.name, name) | |
| 105 | ) | |
| 106 | ) | |
| 107 | .limit(1); | |
| 108 | return row || null; | |
| 109 | } catch (err) { | |
| 110 | console.error("[environments] getByName failed:", err); | |
| 111 | return null; | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | export async function getOrCreateEnvironment( | |
| 116 | repositoryId: string, | |
| 117 | name: string | |
| 118 | ): Promise<Environment> { | |
| 119 | const existing = await getEnvironmentByName(repositoryId, name); | |
| 120 | if (existing) return existing; | |
| 121 | try { | |
| 122 | const [inserted] = await db | |
| 123 | .insert(environments) | |
| 124 | .values({ repositoryId, name }) | |
| 125 | .returning(); | |
| 126 | if (inserted) return inserted; | |
| 127 | } catch (err) { | |
| 128 | // Unique-index collision from a concurrent insert — fall through to re-read. | |
| 129 | console.error("[environments] create failed:", err); | |
| 130 | } | |
| 131 | const reread = await getEnvironmentByName(repositoryId, name); | |
| 132 | if (reread) return reread; | |
| 133 | // Absolute fallback — synthesize an in-memory shell so callers never crash. | |
| 134 | // This path is only reached if the DB is unreachable for both insert + read. | |
| 135 | return { | |
| 136 | id: "", | |
| 137 | repositoryId, | |
| 138 | name, | |
| 139 | requireApproval: false, | |
| 140 | reviewers: "[]", | |
| 141 | waitTimerMinutes: 0, | |
| 142 | allowedBranches: "[]", | |
| 143 | createdAt: new Date(), | |
| 144 | updatedAt: new Date(), | |
| 145 | } as Environment; | |
| 146 | } | |
| 147 | ||
| 148 | // --------------------------------------------------------------------------- | |
| 149 | // Reviewer semantics | |
| 150 | // --------------------------------------------------------------------------- | |
| 151 | ||
| 152 | function parseJsonArray(raw: string | null | undefined): string[] { | |
| 153 | if (!raw) return []; | |
| 154 | try { | |
| 155 | const v = JSON.parse(raw); | |
| 156 | return Array.isArray(v) ? v.map(String) : []; | |
| 157 | } catch { | |
| 158 | return []; | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | export function reviewerIdsOf(env: Environment): string[] { | |
| 163 | return parseJsonArray(env.reviewers); | |
| 164 | } | |
| 165 | ||
| 166 | export function allowedBranchesOf(env: Environment): string[] { | |
| 167 | return parseJsonArray(env.allowedBranches); | |
| 168 | } | |
| 169 | ||
| 170 | /** | |
| 171 | * Return true if `userId` is allowed to approve/reject deploys for this env. | |
| 172 | * If the reviewer list is empty, fall back to the repo owner. | |
| 173 | */ | |
| 174 | export async function isReviewer( | |
| 175 | env: Environment, | |
| 176 | userId: string | |
| 177 | ): Promise<boolean> { | |
| 178 | const reviewers = reviewerIdsOf(env); | |
| 179 | if (reviewers.includes(userId)) return true; | |
| 180 | if (reviewers.length === 0) { | |
| 181 | try { | |
| 182 | const [row] = await db | |
| 183 | .select({ ownerId: repositories.ownerId }) | |
| 184 | .from(repositories) | |
| 185 | .where(eq(repositories.id, env.repositoryId)) | |
| 186 | .limit(1); | |
| 187 | return row?.ownerId === userId; | |
| 188 | } catch (err) { | |
| 189 | console.error("[environments] isReviewer owner lookup failed:", err); | |
| 190 | return false; | |
| 191 | } | |
| 192 | } | |
| 193 | return false; | |
| 194 | } | |
| 195 | ||
| 196 | // --------------------------------------------------------------------------- | |
| 197 | // Approvals | |
| 198 | // --------------------------------------------------------------------------- | |
| 199 | ||
| 200 | export async function listApprovals( | |
| 201 | deploymentId: string | |
| 202 | ): Promise<DeploymentApproval[]> { | |
| 203 | try { | |
| 204 | return await db | |
| 205 | .select() | |
| 206 | .from(deploymentApprovals) | |
| 207 | .where(eq(deploymentApprovals.deploymentId, deploymentId)) | |
| 208 | .orderBy(desc(deploymentApprovals.createdAt)); | |
| 209 | } catch (err) { | |
| 210 | console.error("[environments] listApprovals failed:", err); | |
| 211 | return []; | |
| 212 | } | |
| 213 | } | |
| 214 | ||
| 215 | /** | |
| 216 | * Pure reducer — given a list of decisions, compute approved/rejected flags. | |
| 217 | * Exported so tests can exercise it without a DB. | |
| 218 | */ | |
| 219 | export function reduceApprovalState(decided: DeploymentApproval[]): { | |
| 220 | approved: boolean; | |
| 221 | rejected: boolean; | |
| 222 | decided: DeploymentApproval[]; | |
| 223 | } { | |
| 224 | const rejected = decided.some((d) => d.decision === "rejected"); | |
| 225 | const approved = !rejected && decided.some((d) => d.decision === "approved"); | |
| 226 | return { approved, rejected, decided }; | |
| 227 | } | |
| 228 | ||
| 229 | /** | |
| 230 | * v1 semantics: approved = at least one approval exists and no rejection. | |
| 231 | * rejected = at least one rejection exists. | |
| 232 | */ | |
| 233 | export async function computeApprovalState( | |
| 234 | deploymentId: string, | |
| 235 | _env: Environment | |
| 236 | ): Promise<{ | |
| 237 | approved: boolean; | |
| 238 | rejected: boolean; | |
| 239 | decided: DeploymentApproval[]; | |
| 240 | }> { | |
| 241 | const decided = await listApprovals(deploymentId); | |
| 242 | return reduceApprovalState(decided); | |
| 243 | } | |
| 244 | ||
| 245 | /** | |
| 246 | * Record a reviewer's decision. Returns the inserted row, or null on any | |
| 247 | * failure (duplicate, DB unreachable, etc). | |
| 248 | */ | |
| 249 | export async function recordApproval(opts: { | |
| 250 | deploymentId: string; | |
| 251 | userId: string; | |
| 252 | decision: "approved" | "rejected"; | |
| 253 | comment?: string; | |
| 254 | }): Promise<DeploymentApproval | null> { | |
| 255 | try { | |
| 256 | const [row] = await db | |
| 257 | .insert(deploymentApprovals) | |
| 258 | .values({ | |
| 259 | deploymentId: opts.deploymentId, | |
| 260 | userId: opts.userId, | |
| 261 | decision: opts.decision, | |
| 262 | comment: opts.comment ?? null, | |
| 263 | }) | |
| 264 | .returning(); | |
| 265 | return row || null; | |
| 266 | } catch (err) { | |
| 267 | console.error("[environments] recordApproval failed:", err); | |
| 268 | return null; | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 272 | // --------------------------------------------------------------------------- | |
| 273 | // Push-time gate (called by post-receive before the deploy executes) | |
| 274 | // --------------------------------------------------------------------------- | |
| 275 | ||
| 276 | /** | |
| 277 | * Pure read — returns whether a deploy to (repo, envName, ref) needs approval. | |
| 278 | * | |
| 279 | * { required: true, env } → caller should create the deployment row with | |
| 280 | * status="pending_approval" (or "blocked" if | |
| 281 | * env?.blockedReason would apply — that's the | |
| 282 | * caller's call; we only flag required=true). | |
| 283 | * { required: false, env } → caller may proceed to status="pending". | |
| 284 | * | |
| 285 | * Branch-glob enforcement: if the env's `allowedBranches` is non-empty and the | |
| 286 | * ref does not match any pattern, we still return `required: true` so the | |
| 287 | * caller knows to block. The caller can read `allowedBranchesOf(env)` to set | |
| 288 | * `blockedReason: "branch not allowed for environment"` on the deployment row. | |
| 289 | */ | |
| 290 | export async function requiresApprovalFor( | |
| 291 | repositoryId: string, | |
| 292 | envName: string, | |
| 293 | ref: string | |
| 294 | ): Promise<{ required: boolean; env: Environment | null }> { | |
| 295 | const env = await getEnvironmentByName(repositoryId, envName); | |
| 296 | if (!env) return { required: false, env: null }; | |
| 297 | ||
| 298 | const allowed = allowedBranchesOf(env); | |
| 299 | if (allowed.length > 0 && !matchesAny(ref, allowed)) { | |
| 300 | return { required: true, env }; | |
| 301 | } | |
| 302 | if (env.requireApproval) return { required: true, env }; | |
| 303 | return { required: false, env }; | |
| 304 | } |