Skip to content

g1t/apps/web/app/lib/environments.ts

106 lines4,655 bytesCodeBlame
1/**
2 * An environment's protection rules as the Environments settings page
3 * shows and posts them: what a form says, and a rule set in a few words.
4 */
5import type { BranchPattern, Environment, EnvironmentChange, EnvironmentReviewer } from "@g1t/contracts";
6
7/** What an environment may be called: lowercase letters, digits, `-` and `_`, up to 40. */
8export const ENVIRONMENT_NAME = /^[a-z0-9_-]{1,40}$/;
9
10/** An environment's name as someone typed it, lowercased; null when it cannot be one. */
11export function environmentName(value: string | null | undefined): string | null {
12 const name = (value ?? "").trim().toLowerCase();
13 return ENVIRONMENT_NAME.test(name) ? name : null;
14}
15
16/** The limits the actions service holds rules to. */
17export type EnvironmentLimits = { reviewers: number; waitMinutes: number };
18
19const POLICIES: Environment["branchPolicy"][] = ["all", "protected", "selected"];
20
21/**
22 * The rules a settings form posts: `reviewerType`/`reviewerName` and
23 * `patternType`/`patternName` pairs in order, blanks left out. Reviewers
24 * past the limit are an error; numbers are kept within theirs.
25 */
26export function environmentFromForm(form: FormData, limits: EnvironmentLimits): { change: EnvironmentChange } | { error: string } {
27 const types = form.getAll("reviewerType").map(String);
28 const reviewers: EnvironmentReviewer[] = [];
29 const seen = new Set<string>();
30 form.getAll("reviewerName").forEach((value, index) => {
31 const name = String(value).trim().replace(/^@/, "");
32 const type = types[index] === "team" ? "team" : "user";
33 const key = `${type}:${name.toLowerCase()}`;
34 if (!name || seen.has(key)) return;
35 seen.add(key);
36 reviewers.push({ type, name });
37 });
38 if (reviewers.length > limits.reviewers) {
39 return { error: `An environment can have up to ${limits.reviewers} reviewers.` };
40 }
41
42 const minutes = Math.trunc(Number(form.get("waitMinutes") ?? 0));
43 if (!Number.isFinite(minutes) || minutes < 0 || minutes > limits.waitMinutes) {
44 return { error: `The wait timer is a number of minutes from 0 to ${limits.waitMinutes.toLocaleString("en-US")}.` };
45 }
46
47 const policy = String(form.get("branchPolicy") ?? "all") as Environment["branchPolicy"];
48 const branchPolicy = POLICIES.includes(policy) ? policy : "all";
49 const patternTypes = form.getAll("patternType").map(String);
50 const branchPatterns: BranchPattern[] = [];
51 form.getAll("patternName").forEach((value, index) => {
52 const name = String(value).trim();
53 if (name) branchPatterns.push({ name, type: patternTypes[index] === "tag" ? "tag" : "branch" });
54 });
55 if (branchPolicy === "selected" && branchPatterns.length === 0) {
56 return { error: "Add at least one branch or tag pattern, or choose another option for deployment branches." };
57 }
58
59 return {
60 change: {
61 reviewers,
62 preventSelfReview: form.get("preventSelfReview") === "on",
63 waitMinutes: minutes,
64 branchPolicy,
65 // Patterns only mean something for selected branches and tags.
66 branchPatterns: branchPolicy === "selected" ? branchPatterns : [],
67 adminsBypass: form.get("adminsBypass") === "on",
68 },
69 };
70}
71
72/** `30m`, `2h`, `1d 4h`: a wait timer in short. */
73export function waitWords(minutes: number): string {
74 if (minutes < 60) return `${minutes}m`;
75 const days = Math.floor(minutes / 1440);
76 const hours = Math.floor((minutes % 1440) / 60);
77 const rest = minutes % 60;
78 return [days && `${days}d`, hours && `${hours}h`, rest && `${rest}m`].filter(Boolean).join(" ");
79}
80
81/** A reviewer as the pages show one: a user by name, a team as @team. */
82export function reviewerLabel(reviewer: EnvironmentReviewer): string {
83 return reviewer.type === "team" ? `@${reviewer.name}` : reviewer.name;
84}
85
86/** An environment's rules in a few words each, for its row in the list. */
87export function environmentSummary(environment: Environment): string[] {
88 if (!environment.protected) return [];
89 const parts: string[] = [];
90 const { reviewers } = environment;
91 if (reviewers.length > 0) {
92 parts.push(
93 reviewers.length <= 2
94 ? `Reviewers: ${reviewers.map(reviewerLabel).join(", ")}`
95 : `${reviewers.length} reviewers`,
96 );
97 }
98 if (environment.waitMinutes > 0) parts.push(`Wait ${waitWords(environment.waitMinutes)}`);
99 if (environment.branchPolicy === "protected") parts.push("Protected branches only");
100 if (environment.branchPolicy === "selected") {
101 const count = environment.branchPatterns.length;
102 parts.push(`${count} branch and tag pattern${count === 1 ? "" : "s"}`);
103 }
104 if (parts.length === 0) parts.push("No reviewers, wait timer or branch limits");
105 return parts;
106}