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.tsBlame304 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.
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
16import { and, desc, eq } from "drizzle-orm";
17import { db } from "../db";
18import {
19 environments,
20 deploymentApprovals,
21 repositories,
22} from "../db/schema";
23import 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/`. */
30function 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. */
37export 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
50function 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
59export 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
74export 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
93export 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
115export 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
152function 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
162export function reviewerIdsOf(env: Environment): string[] {
163 return parseJsonArray(env.reviewers);
164}
165
166export 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 */
174export 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
200export 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 */
219export 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 */
233export 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 */
249export 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 */
290export 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}