| 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 | */ |
| 5 | import type { BranchPattern, Environment, EnvironmentChange, EnvironmentReviewer } from "@g1t/contracts"; |
| 6 | |
| 7 | /** What an environment may be called: lowercase letters, digits, `-` and `_`, up to 40. */ |
| 8 | export 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. */ |
| 11 | export 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. */ |
| 17 | export type EnvironmentLimits = { reviewers: number; waitMinutes: number }; |
| 18 | |
| 19 | const 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 | */ |
| 26 | export 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. */ |
| 73 | export 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. */ |
| 82 | export 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. */ |
| 87 | export 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 | } |