Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

environments.tsBlame400 lines · 1 contributor
25a91a6Claude1/**
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.
a76d984Claude10 * - `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".
25a91a6Claude13 *
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
a76d984Claude18import { and, desc, eq, isNotNull, lte, sql } from "drizzle-orm";
25a91a6Claude19import { db } from "../db";
20import {
21 environments,
22 deploymentApprovals,
a76d984Claude23 deployments,
25a91a6Claude24 repositories,
25} from "../db/schema";
26import 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/`. */
33function 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. */
40export 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
53function 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
62export 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
77export 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
96export 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
118export 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
155function 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
165export function reviewerIdsOf(env: Environment): string[] {
166 return parseJsonArray(env.reviewers);
167}
168
169export 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 */
177export 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
203export 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 */
222export 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
a76d984Claude232/**
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 */
237export 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 */
258export 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
25a91a6Claude269/**
270 * v1 semantics: approved = at least one approval exists and no rejection.
271 * rejected = at least one rejection exists.
a76d984Claude272 *
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.
25a91a6Claude282 */
283export async function computeApprovalState(
284 deploymentId: string,
a76d984Claude285 env: Environment
25a91a6Claude286): Promise<{
287 approved: boolean;
288 rejected: boolean;
289 decided: DeploymentApproval[];
a76d984Claude290 readyAfter: Date | null;
25a91a6Claude291}> {
292 const decided = await listApprovals(deploymentId);
a76d984Claude293 const base = reduceApprovalState(decided);
294 const readyAfter = base.approved ? computeReadyAfter(env, decided) : null;
295 return { ...base, readyAfter };
25a91a6Claude296}
297
298/**
299 * Record a reviewer's decision. Returns the inserted row, or null on any
300 * failure (duplicate, DB unreachable, etc).
301 */
302export 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 */
343export 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}
a76d984Claude358
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 */
370export 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 */
397export const __test = {
398 latestApprovalAt,
399 computeReadyAfter,
400};